diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index 5d91a6b..e22934e 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -155,6 +155,16 @@ "charger.editor.field.power" = "Charge Power"; "charger.editor.field.power.footer" = "Leave blank when the rated wattage isn't published. We'll calculate it from voltage and current."; "charger.editor.default_name" = "New Charger"; +"charger.editor.alert.input_voltage.title" = "Edit Input Voltage"; +"charger.editor.alert.output_voltage.title" = "Edit Output Voltage"; +"charger.editor.alert.current.title" = "Edit Charge Current"; +"charger.editor.alert.voltage.message" = "Enter voltage in volts (V)"; +"charger.editor.alert.power.title" = "Edit Charge Power"; +"charger.editor.alert.power.placeholder" = "Power"; +"charger.editor.alert.power.message" = "Enter power in watts (W)"; +"charger.editor.alert.current.message" = "Enter current in amps (A)"; +"charger.editor.alert.cancel" = "Cancel"; +"charger.editor.alert.save" = "Save"; "charger.default.new" = "New Charger"; "chargers.summary.title" = "Charging Overview"; diff --git a/Cable/Chargers/ChargerEditorView.swift b/Cable/Chargers/ChargerEditorView.swift index eac5d17..1fd4ae3 100644 --- a/Cable/Chargers/ChargerEditorView.swift +++ b/Cable/Chargers/ChargerEditorView.swift @@ -2,33 +2,51 @@ import SwiftUI struct ChargerEditorView: View { @State private var configuration: ChargerConfiguration + @State private var editingField: EditingField? + @State private var inputVoltageInput: String = "" + @State private var outputVoltageInput: String = "" + @State private var currentInput: String = "" + @State private var powerInput: String = "" + @State private var powerEntryMode: PowerEntryMode + @State private var lastManualPowerWatts: Double @State private var showingAppearanceEditor = false - let onSave: (ChargerConfiguration) -> Void + private enum EditingField { + case inputVoltage + case outputVoltage + case current + case power + } + + private enum PowerEntryMode { + case current + case power + } + + private let inputVoltageSnapValues: [Double] = [12, 24, 48, 120, 230, 240] + private let outputVoltageSnapValues: [Double] = [12, 12.6, 12.8, 14.2, 24, 48] + private let currentSnapValues: [Double] = [5, 10, 15, 20, 25, 30, 40, 50, 60, 80, 100, 150, 200] + private let powerSnapValues: [Double] = [100, 150, 200, 250, 300, 400, 500, 600, 750, 1000, 1250, 1500, 2000, 2500, 3000] + private let inputVoltageSnapTolerance: Double = 2.0 + private let outputVoltageSnapTolerance: Double = 0.5 + private let currentSnapTolerance: Double = 2.0 + private let powerSnapTolerance: Double = 25.0 private let chargerIconOptions: [String] = [ "bolt.fill", + "bolt", "bolt.circle", + "bolt.circle.fill", + "bolt.horizontal.circle", "bolt.square", + "bolt.square.fill", "bolt.badge.clock", - "bolt.horizontal", + "bolt.badge.a", "powerplug", - "battery.100.bolt", - "car.battery", - "engine.combustion", - "fanblades", - "generator" + "flashlight.on.fill", + "battery.100.bolt" ] - private var editorTitle: String { - NSLocalizedString( - "charger.editor.title", - bundle: .main, - value: "Charger", - comment: "Navigation bar title for the charger editor" - ) - } - private var nameFieldLabel: String { String( localized: "charger.editor.field.name", @@ -45,19 +63,59 @@ struct ChargerEditorView: View { ) } - private var electricalSectionTitle: String { + private var electricalSectionLabel: String { String( localized: "charger.editor.section.electrical", bundle: .main, - comment: "Section title for charger electrical configuration" + comment: "Label for the electrical section" ) } - private var powerSectionTitle: String { + private var chargingSectionLabel: String { String( localized: "charger.editor.section.power", bundle: .main, - comment: "Section title for charger output power configuration" + comment: "Label for the charging output section" + ) + } + + private var inputVoltageLabel: String { + String( + localized: "charger.editor.field.input_voltage", + bundle: .main, + comment: "Label for the input voltage slider" + ) + } + + private var outputVoltageLabel: String { + String( + localized: "charger.editor.field.output_voltage", + bundle: .main, + comment: "Label for the output voltage slider" + ) + } + + private var currentLabel: String { + String( + localized: "charger.editor.field.current", + bundle: .main, + comment: "Label for the charging current slider" + ) + } + + private var powerLabel: String { + String( + localized: "charger.editor.field.power", + bundle: .main, + comment: "Label for the optional power field" + ) + } + + private var powerFooter: String { + String( + localized: "charger.editor.field.power.footer", + bundle: .main, + comment: "Footer text describing how the optional power field works" ) } @@ -88,8 +146,199 @@ struct ChargerEditorView: View { ) } + private var inputBadgeLabel: String { + String( + localized: "chargers.badge.input", + bundle: .main, + comment: "Label for input voltage badges" + ) + } + + private var outputBadgeLabel: String { + String( + localized: "chargers.badge.output", + bundle: .main, + comment: "Label for output voltage badges" + ) + } + + private var currentBadgeLabel: String { + String( + localized: "chargers.badge.current", + bundle: .main, + comment: "Label for charging current badges" + ) + } + + private var powerBadgeLabel: String { + String( + localized: "chargers.badge.power", + bundle: .main, + comment: "Label for charging power badges" + ) + } + + private var wattButtonTitle: String { + String( + localized: "slider.button.watt", + bundle: .main, + comment: "Button label when switching to power entry mode" + ) + } + + private var ampButtonTitle: String { + String( + localized: "slider.button.ampere", + bundle: .main, + comment: "Button label when switching to current entry mode" + ) + } + + private var inputVoltageAlertTitle: String { + NSLocalizedString( + "charger.editor.alert.input_voltage.title", + bundle: .main, + value: "Edit Input Voltage", + comment: "Title for the input voltage edit alert" + ) + } + + private var outputVoltageAlertTitle: String { + NSLocalizedString( + "charger.editor.alert.output_voltage.title", + bundle: .main, + value: "Edit Output Voltage", + comment: "Title for the output voltage edit alert" + ) + } + + private var currentAlertTitle: String { + NSLocalizedString( + "charger.editor.alert.current.title", + bundle: .main, + value: "Edit Charge Current", + comment: "Title for the charging current edit alert" + ) + } + + private var powerAlertTitle: String { + NSLocalizedString( + "charger.editor.alert.power.title", + bundle: .main, + value: "Edit Charge Power", + comment: "Title for the power edit alert" + ) + } + + private var alertMessageVoltage: String { + NSLocalizedString( + "charger.editor.alert.voltage.message", + bundle: .main, + value: "Enter voltage in volts (V)", + comment: "Message for voltage edit alerts" + ) + } + + private var alertMessagePower: String { + NSLocalizedString( + "charger.editor.alert.power.message", + bundle: .main, + value: "Enter power in watts (W)", + comment: "Message for the power edit alert" + ) + } + + private var alertMessageCurrent: String { + NSLocalizedString( + "charger.editor.alert.current.message", + bundle: .main, + value: "Enter current in amps (A)", + comment: "Message for the current edit alert" + ) + } + + private var alertCancelTitle: String { + NSLocalizedString( + "charger.editor.alert.cancel", + bundle: .main, + value: "Cancel", + comment: "Title for cancel buttons in edit alerts" + ) + } + + private var alertSaveTitle: String { + NSLocalizedString( + "charger.editor.alert.save", + bundle: .main, + value: "Save", + comment: "Title for save buttons in edit alerts" + ) + } + + private var powerAlertPlaceholder: String { + NSLocalizedString( + "charger.editor.alert.power.placeholder", + bundle: .main, + value: "Power", + comment: "Placeholder for the power edit alert" + ) + } + + private var iconColor: Color { + Color.componentColor(named: configuration.colorName) + } + + private var displayName: String { + configuration.name.isEmpty ? namePlaceholder : configuration.name + } + + private var inputVoltageSliderRange: ClosedRange { + let lowerBound = max(0, min(12, configuration.inputVoltage)) + let upperBound = max(300, configuration.inputVoltage) + return lowerBound...upperBound + } + + private var outputVoltageSliderRange: ClosedRange { + let lowerBound = max(0, min(10, configuration.outputVoltage)) + let upperBound = max(80, configuration.outputVoltage) + return lowerBound...upperBound + } + + private var currentSliderRange: ClosedRange { + let lowerBound = max(0, min(5, configuration.maxCurrentAmps)) + let upperBound = max(200, configuration.maxCurrentAmps) + return lowerBound...upperBound + } + + private var powerSliderRange: ClosedRange { + let effectivePower = configuration.effectivePowerWatts + let upperBound = max(3000, max(configuration.maxPowerWatts, effectivePower)) + return 0...upperBound + } + init(configuration: ChargerConfiguration, onSave: @escaping (ChargerConfiguration) -> Void) { - _configuration = State(initialValue: configuration) + var adjustedConfiguration = configuration + if configuration.maxPowerWatts > 0 { + let voltage = max(configuration.outputVoltage, 0.1) + let derivedCurrent = configuration.maxPowerWatts / voltage + let roundedCurrent = max(0, (derivedCurrent * 10).rounded() / 10) + adjustedConfiguration.maxCurrentAmps = roundedCurrent + } + _configuration = State(initialValue: adjustedConfiguration) + _powerEntryMode = State(initialValue: adjustedConfiguration.maxPowerWatts > 0 ? .power : .current) + let initialPowerCandidate = adjustedConfiguration.maxPowerWatts > 0 + ? adjustedConfiguration.maxPowerWatts + : max(0, adjustedConfiguration.outputVoltage * adjustedConfiguration.maxCurrentAmps) + let roundedInitialPower = max(0, (initialPowerCandidate / 5).rounded() * 5) + let snapValues = [100.0, 150.0, 200.0, 250.0, 300.0, 400.0, 500.0, 600.0, 750.0, 1000.0, 1250.0, 1500.0, 2000.0, 2500.0, 3000.0] + let closestSnap = snapValues.min { abs($0 - roundedInitialPower) < abs($1 - roundedInitialPower) } + let normalizedInitialPower: Double + if let closestSnap, abs(closestSnap - roundedInitialPower) <= 25.0 { + normalizedInitialPower = closestSnap + } else { + normalizedInitialPower = roundedInitialPower + } + _lastManualPowerWatts = State(initialValue: normalizedInitialPower) self.onSave = onSave } @@ -97,23 +346,25 @@ struct ChargerEditorView: View { VStack(spacing: 0) { headerInfoBar List { - infoSection electricalSection - powerSection + chargingSection } - .listStyle(.insetGrouped) + .listStyle(.plain) .scrollIndicators(.hidden) .scrollContentBackground(.hidden) .background(Color.clear) } .background(Color(.systemGroupedBackground).ignoresSafeArea()) + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { - Text(editorTitle) - .font(.headline.weight(.semibold)) + navigationTitleView } } + .onDisappear { + onSave(configuration) + } .sheet(isPresented: $showingAppearanceEditor) { ItemEditorView( title: appearanceEditorTitle, @@ -134,211 +385,566 @@ struct ChargerEditorView: View { ) ) } - .onDisappear { - onSave(configuration) - } - } - - private var headerInfoBar: some View { - HStack(alignment: .center, spacing: 16) { - LoadIconView( - remoteIconURLString: nil, - fallbackSystemName: configuration.iconName, - fallbackColor: Color.componentColor(named: configuration.colorName), - size: 56 + .alert( + inputVoltageAlertTitle, + isPresented: Binding( + get: { editingField == .inputVoltage }, + set: { isPresented in + if !isPresented { + editingField = nil + inputVoltageInput = "" + } + } ) - - VStack(alignment: .leading, spacing: 6) { - Text(configuration.name.isEmpty ? namePlaceholder : configuration.name) - .font(.title3.weight(.semibold)) - .lineLimit(1) - .truncationMode(.tail) - - Text(chargerSummary) - .font(.footnote.weight(.medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - } - .padding(.horizontal, 20) - .padding(.vertical, 16) - .background(Color(.systemBackground)) - } - - private var infoSection: some View { - Section { + ) { TextField( - nameFieldLabel, - text: Binding( - get: { configuration.name }, - set: { configuration.name = $0 } - ), - prompt: Text(namePlaceholder) + inputVoltageLabel, + text: $inputVoltageInput ) - .textInputAutocapitalization(.words) - .disableAutocorrection(true) - - Button(action: { showingAppearanceEditor = true }) { - Label( - appearanceEditorTitle, - systemImage: "paintbrush.pointed" - ) + .keyboardType(.decimalPad) + .onAppear { + if inputVoltageInput.isEmpty { + inputVoltageInput = formattedEditValue(configuration.inputVoltage) + } } - .accessibilityLabel(appearanceAccessibilityLabel) + .onChange(of: inputVoltageInput) { _, newValue in + guard editingField == .inputVoltage, let parsed = parseInput(newValue) else { return } + configuration.inputVoltage = roundToTenth(parsed) + } + + Button(alertCancelTitle, role: .cancel) { + editingField = nil + inputVoltageInput = "" + } + + Button(alertSaveTitle) { + if let parsed = parseInput(inputVoltageInput) { + configuration.inputVoltage = roundToTenth(parsed) + } + editingField = nil + inputVoltageInput = "" + } + } message: { + Text(alertMessageVoltage) } + .alert( + outputVoltageAlertTitle, + isPresented: Binding( + get: { editingField == .outputVoltage }, + set: { isPresented in + if !isPresented { + editingField = nil + outputVoltageInput = "" + } + } + ) + ) { + TextField( + outputVoltageLabel, + text: $outputVoltageInput + ) + .keyboardType(.decimalPad) + .onAppear { + if outputVoltageInput.isEmpty { + outputVoltageInput = formattedEditValue(configuration.outputVoltage) + } + } + .onChange(of: outputVoltageInput) { _, newValue in + guard editingField == .outputVoltage, let parsed = parseInput(newValue) else { return } + configuration.outputVoltage = roundToTenth(parsed) + if powerEntryMode == .power { + synchronizeCurrentWithPower() + } else { + updatePowerFromCurrent() + } + } + + Button(alertCancelTitle, role: .cancel) { + editingField = nil + outputVoltageInput = "" + } + + Button(alertSaveTitle) { + if let parsed = parseInput(outputVoltageInput) { + configuration.outputVoltage = roundToTenth(parsed) + if powerEntryMode == .power { + synchronizeCurrentWithPower() + } else { + updatePowerFromCurrent() + } + } + editingField = nil + outputVoltageInput = "" + } + } message: { + Text(alertMessageVoltage) + } + .alert( + currentAlertTitle, + isPresented: Binding( + get: { editingField == .current }, + set: { isPresented in + if !isPresented { + editingField = nil + currentInput = "" + } + } + ) + ) { + TextField( + currentLabel, + text: $currentInput + ) + .keyboardType(.decimalPad) + .onAppear { + if currentInput.isEmpty { + currentInput = formattedEditValue(configuration.maxCurrentAmps) + } + } + .onChange(of: currentInput) { _, newValue in + guard editingField == .current, let parsed = parseInput(newValue) else { return } + configuration.maxCurrentAmps = roundToTenth(parsed) + configuration.maxPowerWatts = 0 + updatePowerFromCurrent() + } + + Button(alertCancelTitle, role: .cancel) { + editingField = nil + currentInput = "" + } + + Button(alertSaveTitle) { + if let parsed = parseInput(currentInput) { + configuration.maxCurrentAmps = roundToTenth(parsed) + configuration.maxPowerWatts = 0 + updatePowerFromCurrent() + } + editingField = nil + currentInput = "" + } + } message: { + Text(alertMessageCurrent) + } + .alert( + powerAlertTitle, + isPresented: Binding( + get: { editingField == .power }, + set: { isPresented in + if !isPresented { + editingField = nil + powerInput = "" + } + } + ) + ) { + TextField( + powerAlertPlaceholder, + text: $powerInput + ) + .keyboardType(.decimalPad) + .onAppear { + if powerInput.isEmpty { + powerInput = formattedPowerEditValue(displayedPowerValue) + } + } + .onChange(of: powerInput) { _, newValue in + guard editingField == .power, let parsed = parseInput(newValue) else { return } + let normalized = normalizedPower(for: parsed) + configuration.maxPowerWatts = normalized + lastManualPowerWatts = normalized + synchronizeCurrentWithPower() + } + + Button(alertCancelTitle, role: .cancel) { + editingField = nil + powerInput = "" + } + + Button(alertSaveTitle) { + if let parsed = parseInput(powerInput) { + let normalized = normalizedPower(for: parsed) + configuration.maxPowerWatts = normalized + lastManualPowerWatts = normalized + powerEntryMode = .power + synchronizeCurrentWithPower() + } + editingField = nil + powerInput = "" + } + } message: { + Text(alertMessagePower) + } + } + + private var navigationTitleView: some View { + Button { + showingAppearanceEditor = true + } label: { + HStack(spacing: 8) { + LoadIconView( + remoteIconURLString: nil, + fallbackSystemName: configuration.iconName.isEmpty ? "bolt.fill" : configuration.iconName, + fallbackColor: iconColor, + size: 26 + ) + Text(displayName) + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.primary) + } + } + .buttonStyle(.plain) + .accessibilityLabel(appearanceAccessibilityLabel) } private var electricalSection: some View { - Section(header: Text(electricalSectionTitle)) { - valueRow( - title: String( - localized: "charger.editor.field.input_voltage", - bundle: .main, - comment: "Label for the charger input voltage field" + Section { + SliderSection( + title: inputVoltageLabel, + value: Binding( + get: { configuration.inputVoltage }, + set: { newValue in + if editingField == .inputVoltage { + configuration.inputVoltage = roundToTenth(newValue) + } else { + configuration.inputVoltage = normalizedInputVoltage(for: newValue) + } + } ), - value: $configuration.inputVoltage, + range: inputVoltageSliderRange, unit: "V", - range: 0...400, - step: 1 + tapAction: beginInputVoltageEditing, + snapValues: editingField == .inputVoltage ? nil : inputVoltageSnapValues ) + .listRowSeparator(.hidden) - valueRow( - title: String( - localized: "charger.editor.field.output_voltage", - bundle: .main, - comment: "Label for the charger output voltage field" + SliderSection( + title: outputVoltageLabel, + value: Binding( + get: { configuration.outputVoltage }, + set: { newValue in + if editingField == .outputVoltage { + configuration.outputVoltage = roundToTenth(newValue) + } else { + configuration.outputVoltage = normalizedOutputVoltage(for: newValue) + } + if powerEntryMode == .power { + synchronizeCurrentWithPower() + } else { + updatePowerFromCurrent() + } + } ), - value: $configuration.outputVoltage, + range: outputVoltageSliderRange, unit: "V", - range: 0...80, - step: 0.1 + tapAction: beginOutputVoltageEditing, + snapValues: editingField == .outputVoltage ? nil : outputVoltageSnapValues ) + .listRowSeparator(.hidden) } + .listRowBackground(Color(.systemBackground)) + .listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18)) } - private var powerSection: some View { - Section(header: Text(powerSectionTitle), footer: powerFooterText) { - valueRow( - title: String( - localized: "charger.editor.field.current", - bundle: .main, - comment: "Label for the charger current field" - ), - value: $configuration.maxCurrentAmps, - unit: "A", - range: 0...200, - step: 0.5 - ) - - valueRow( - title: String( - localized: "charger.editor.field.power", - bundle: .main, - comment: "Label for the charger power field" - ), - value: $configuration.maxPowerWatts, - unit: "W", - range: 0...10000, - step: 50, - allowsZero: true - ) - } - } - - private func valueRow( - title: String, - value: Binding, - unit: String, - range: ClosedRange, - step: Double, - allowsZero: Bool = false - ) -> some View { - LabeledContent { - HStack(spacing: 8) { - Stepper( - value: Binding( - get: { - clamp(value.wrappedValue, to: range, allowsZero: allowsZero) - }, - set: { newValue in - let clamped = clamp(newValue, to: range, allowsZero: allowsZero) - if abs(clamped - value.wrappedValue) > .ulpOfOne { - value.wrappedValue = clamped + private var chargingSection: some View { + Section { + if powerEntryMode == .power { + VStack(alignment: .leading, spacing: 12) { + SliderSection( + title: powerLabel, + value: Binding( + get: { displayedPowerValue }, + set: { newValue in + let normalized = editingField == .power ? roundToNearestFive(newValue) : normalizedPower(for: newValue) + configuration.maxPowerWatts = normalized + lastManualPowerWatts = normalized + synchronizeCurrentWithPower() } + ), + range: powerSliderRange, + unit: "W", + buttonText: ampButtonTitle, + buttonAction: switchToCurrentMode, + tapAction: beginPowerEditing, + snapValues: editingField == .power ? nil : powerSnapValues + ) + + } + .listRowSeparator(.hidden) + } else { + SliderSection( + title: currentLabel, + value: Binding( + get: { configuration.maxCurrentAmps }, + set: { newValue in + if editingField == .current { + configuration.maxCurrentAmps = roundToTenth(newValue) + } else { + configuration.maxCurrentAmps = normalizedCurrent(for: newValue) + } + configuration.maxPowerWatts = 0 + updatePowerFromCurrent() } ), - in: range, - step: step - ) { - EmptyView() - } - .labelsHidden() - TextField( - "", - value: value, - format: .number.precision(.fractionLength(1)) + range: currentSliderRange, + unit: "A", + buttonText: wattButtonTitle, + buttonAction: switchToPowerMode, + tapAction: beginCurrentEditing, + snapValues: editingField == .current ? nil : currentSnapValues ) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(minWidth: 64) - Text(unit) - .foregroundStyle(.secondary) + .listRowSeparator(.hidden) + } + } + .listRowBackground(Color(.systemBackground)) + .listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18)) + } + + private var headerInfoBar: some View { + VStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + overviewChip( + icon: "powerplug", + title: inputBadgeLabel.uppercased(), + value: formattedVoltage(configuration.inputVoltage), + tint: .indigo + ) + + overviewChip( + icon: "bolt.fill", + title: outputBadgeLabel.uppercased(), + value: formattedVoltage(configuration.outputVoltage), + tint: .green + ) + + overviewChip( + icon: "gauge.medium", + title: currentBadgeLabel.uppercased(), + value: formattedCurrent(configuration.maxCurrentAmps), + tint: .orange + ) + + overviewChip( + icon: "bolt.circle", + title: powerBadgeLabel.uppercased(), + value: formattedPower(configuration.effectivePowerWatts), + tint: .pink + ) + } + .padding(.horizontal, 18) + } + .scrollClipDisabled(true) + } + .padding(.vertical, 14) + .background(Color(.systemGroupedBackground)) + } + + private func overviewChip(icon: String, title: String, value: String, tint: Color) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + Text(value) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) } - } label: { Text(title) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.secondary) } - } - - private func clamp(_ value: Double, to range: ClosedRange, allowsZero: Bool) -> Double { - if allowsZero && value == 0 { - return 0 - } - return min(max(value, range.lowerBound), range.upperBound) - } - - private var chargerSummary: String { - let input = formattedValue(configuration.inputVoltage, unit: "V") - let output = formattedValue(configuration.outputVoltage, unit: "V") - let current = formattedValue(configuration.maxCurrentAmps, unit: "A") - return [input, output, current].joined(separator: " • ") - } - - private var powerFooterText: Text { - Text( - String( - localized: "charger.editor.field.power.footer", - bundle: .main, - comment: "Footer text explaining power input behaviour" - ) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(tint.opacity(0.12)) ) } - private func formattedValue(_ value: Double, unit: String) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = unit == "A" ? 1 : 1 - formatter.minimumFractionDigits = 0 - guard let formatted = formatter.string(from: value as NSNumber) else { - return "\(value) \(unit)" + private var displayedPowerValue: Double { + if configuration.maxPowerWatts > 0 { + return configuration.maxPowerWatts } - return "\(formatted)\(unit)" + if lastManualPowerWatts > 0 { + return lastManualPowerWatts + } + return max(0, configuration.outputVoltage * configuration.maxCurrentAmps) } + + private func switchToPowerMode() { + if configuration.maxPowerWatts <= 0 { + let candidate = lastManualPowerWatts > 0 ? lastManualPowerWatts : configuration.outputVoltage * configuration.maxCurrentAmps + let normalized = normalizedPower(for: candidate) + configuration.maxPowerWatts = normalized + lastManualPowerWatts = normalized + } else { + lastManualPowerWatts = configuration.maxPowerWatts + } + powerEntryMode = .power + synchronizeCurrentWithPower() + } + + private func switchToCurrentMode() { + if configuration.maxPowerWatts > 0 { + lastManualPowerWatts = configuration.maxPowerWatts + } + configuration.maxPowerWatts = 0 + powerEntryMode = .current + updatePowerFromCurrent() + } + + private func synchronizeCurrentWithPower() { + guard powerEntryMode == .power else { return } + guard configuration.maxPowerWatts > 0 else { + configuration.maxCurrentAmps = 0 + return + } + let voltage = max(configuration.outputVoltage, 0.1) + let derivedCurrent = configuration.maxPowerWatts / voltage + configuration.maxCurrentAmps = roundToTenth(derivedCurrent) + } + + private func updatePowerFromCurrent() { + let derivedPower = configuration.outputVoltage * configuration.maxCurrentAmps + lastManualPowerWatts = normalizedPower(for: derivedPower) + } + + private func normalizedInputVoltage(for value: Double) -> Double { + let rounded = roundToTenth(value) + if let snapped = nearestValue(to: rounded, in: inputVoltageSnapValues, tolerance: inputVoltageSnapTolerance) { + return snapped + } + return rounded + } + + private func normalizedOutputVoltage(for value: Double) -> Double { + let rounded = roundToTenth(value) + if let snapped = nearestValue(to: rounded, in: outputVoltageSnapValues, tolerance: outputVoltageSnapTolerance) { + return snapped + } + return rounded + } + + private func normalizedCurrent(for value: Double) -> Double { + let rounded = roundToTenth(value) + if let snapped = nearestValue(to: rounded, in: currentSnapValues, tolerance: currentSnapTolerance) { + return snapped + } + return rounded + } + + private func normalizedPower(for value: Double) -> Double { + let rounded = roundToNearestFive(value) + if let snapped = nearestValue(to: rounded, in: powerSnapValues, tolerance: powerSnapTolerance) { + return snapped + } + return rounded + } + + 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 formattedEditValue(_ value: Double) -> String { + Self.decimalFormatter.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.decimalFormatter.number(from: trimmed)?.doubleValue { + return number + } + let decimalSeparator = Locale.current.decimalSeparator ?? "." + let altSeparator = decimalSeparator == "." ? "," : "." + let normalized = trimmed.replacingOccurrences(of: altSeparator, with: decimalSeparator) + return Self.decimalFormatter.number(from: normalized)?.doubleValue + } + + private func beginInputVoltageEditing() { + inputVoltageInput = formattedEditValue(configuration.inputVoltage) + editingField = .inputVoltage + } + + private func beginOutputVoltageEditing() { + outputVoltageInput = formattedEditValue(configuration.outputVoltage) + editingField = .outputVoltage + } + + private func beginCurrentEditing() { + currentInput = formattedEditValue(configuration.maxCurrentAmps) + editingField = .current + } + + private func beginPowerEditing() { + powerInput = formattedPowerEditValue(displayedPowerValue) + editingField = .power + } + + 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 formattedVoltage(_ value: Double) -> String { + let numberString = Self.decimalFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value) + return "\(numberString) V" + } + + private func formattedCurrent(_ value: Double) -> String { + let numberString = Self.decimalFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value) + return "\(numberString) A" + } + + private func formattedPower(_ value: Double) -> String { + guard value > 0 else { return "— W" } + let numberString = Self.powerFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value) + return "\(numberString) W" + } + + private func formattedPowerEditValue(_ value: Double) -> String { + guard value > 0 else { return "" } + return Self.powerFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value) + } + + private static let decimalFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.locale = .current + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 1 + return formatter + }() + + private static let powerFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.locale = .current + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 0 + return formatter + }() } #Preview { - NavigationView { + let previewSystem = ElectricalSystem(name: "Camper") + return NavigationStack { ChargerEditorView( configuration: ChargerConfiguration( - name: "Shore Power Charger", + name: "Workshop Charger", inputVoltage: 230, outputVoltage: 14.4, maxCurrentAmps: 40, - maxPowerWatts: 600, + maxPowerWatts: 580, iconName: "bolt.fill", colorName: "orange", - system: ElectricalSystem(name: "Preview System") + system: previewSystem ), onSave: { _ in } ) diff --git a/Cable/Chargers/ChargersView.swift b/Cable/Chargers/ChargersView.swift index 8fe808e..2a4ef20 100644 --- a/Cable/Chargers/ChargersView.swift +++ b/Cable/Chargers/ChargersView.swift @@ -162,11 +162,11 @@ struct ChargersView: View { .scrollClipDisabled(true) } .padding(.horizontal, 16) - .padding(.vertical, 16) + .padding(.vertical, 10) Divider() .background(Color(.separator)) - .padding(.leading, 16) + .padding(.leading, 0) } .background(Color(.systemGroupedBackground)) } diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index aec7bf8..219ed05 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -223,6 +223,16 @@ "charger.editor.field.power" = "Ladeleistung"; "charger.editor.field.power.footer" = "Leer lassen, wenn keine Leistungsangabe vorliegt. Wir berechnen sie aus Spannung und Strom."; "charger.editor.default_name" = "Neues Ladegerät"; +"charger.editor.alert.input_voltage.title" = "Eingangsspannung bearbeiten"; +"charger.editor.alert.output_voltage.title" = "Ausgangsspannung bearbeiten"; +"charger.editor.alert.current.title" = "Ladestrom bearbeiten"; +"charger.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben"; +"charger.editor.alert.power.title" = "Ladeleistung bearbeiten"; +"charger.editor.alert.power.placeholder" = "Leistung"; +"charger.editor.alert.power.message" = "Leistung in Watt (W) eingeben"; +"charger.editor.alert.current.message" = "Strom in Ampere (A) eingeben"; +"charger.editor.alert.cancel" = "Abbrechen"; +"charger.editor.alert.save" = "Speichern"; "charger.default.new" = "Neues Ladegerät"; "chargers.summary.title" = "Ladeübersicht"; diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings index 00af4da..b0cb31d 100644 --- a/Cable/es.lproj/Localizable.strings +++ b/Cable/es.lproj/Localizable.strings @@ -222,6 +222,16 @@ "charger.editor.field.power" = "Potencia de carga"; "charger.editor.field.power.footer" = "Déjalo en blanco si no se publica la potencia nominal. La calcularemos a partir del voltaje y la corriente."; "charger.editor.default_name" = "Nuevo cargador"; +"charger.editor.alert.input_voltage.title" = "Editar voltaje de entrada"; +"charger.editor.alert.output_voltage.title" = "Editar voltaje de salida"; +"charger.editor.alert.current.title" = "Editar corriente de carga"; +"charger.editor.alert.voltage.message" = "Introduce el voltaje en voltios (V)"; +"charger.editor.alert.power.title" = "Editar potencia de carga"; +"charger.editor.alert.power.placeholder" = "Potencia"; +"charger.editor.alert.power.message" = "Introduce la potencia en vatios (W)"; +"charger.editor.alert.current.message" = "Introduce la corriente en amperios (A)"; +"charger.editor.alert.cancel" = "Cancelar"; +"charger.editor.alert.save" = "Guardar"; "charger.default.new" = "Nuevo cargador"; "chargers.summary.title" = "Resumen de carga"; diff --git a/Cable/fr.lproj/Localizable.strings b/Cable/fr.lproj/Localizable.strings index 23a5e31..3fabd71 100644 --- a/Cable/fr.lproj/Localizable.strings +++ b/Cable/fr.lproj/Localizable.strings @@ -222,6 +222,16 @@ "charger.editor.field.power" = "Puissance de charge"; "charger.editor.field.power.footer" = "Laissez vide si la puissance nominale n'est pas indiquée. Nous la calculerons à partir de la tension et du courant."; "charger.editor.default_name" = "Nouveau chargeur"; +"charger.editor.alert.input_voltage.title" = "Modifier la tension d'entrée"; +"charger.editor.alert.output_voltage.title" = "Modifier la tension de sortie"; +"charger.editor.alert.current.title" = "Modifier le courant de charge"; +"charger.editor.alert.voltage.message" = "Saisissez la tension en volts (V)"; +"charger.editor.alert.power.title" = "Modifier la puissance de charge"; +"charger.editor.alert.power.placeholder" = "Puissance"; +"charger.editor.alert.power.message" = "Saisissez la puissance en watts (W)"; +"charger.editor.alert.current.message" = "Saisissez le courant en ampères (A)"; +"charger.editor.alert.cancel" = "Annuler"; +"charger.editor.alert.save" = "Enregistrer"; "charger.default.new" = "Nouveau chargeur"; "chargers.summary.title" = "Aperçu de charge"; diff --git a/Cable/nl.lproj/Localizable.strings b/Cable/nl.lproj/Localizable.strings index 07b8cd5..b57168a 100644 --- a/Cable/nl.lproj/Localizable.strings +++ b/Cable/nl.lproj/Localizable.strings @@ -222,6 +222,16 @@ "charger.editor.field.power" = "Laadvermogen"; "charger.editor.field.power.footer" = "Laat leeg als het opgegeven vermogen ontbreekt. We berekenen het uit spanning en stroom."; "charger.editor.default_name" = "Nieuwe lader"; +"charger.editor.alert.input_voltage.title" = "Ingangsspanning bewerken"; +"charger.editor.alert.output_voltage.title" = "Uitgangsspanning bewerken"; +"charger.editor.alert.current.title" = "Laadstroom bewerken"; +"charger.editor.alert.voltage.message" = "Voer de spanning in volt (V) in"; +"charger.editor.alert.power.title" = "Laadvermogen bewerken"; +"charger.editor.alert.power.placeholder" = "Vermogen"; +"charger.editor.alert.power.message" = "Voer het vermogen in watt (W) in"; +"charger.editor.alert.current.message" = "Voer de stroom in ampère (A) in"; +"charger.editor.alert.cancel" = "Annuleren"; +"charger.editor.alert.save" = "Opslaan"; "charger.default.new" = "Nieuwe lader"; "chargers.summary.title" = "Laadoverzicht";