Files
Cable/Cable/Batteries/BatteryEditorView.swift
Stefan Lange-Hegermann ced06f9eb6 ads tracking
2025-11-05 11:13:54 +01:00

1319 lines
47 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct BatteryEditorView: View {
@EnvironmentObject private var unitSettings: UnitSystemSettings
@State private var configuration: BatteryConfiguration
@State private var temperatureEditingField: TemperatureEditingField?
@State private var isAdvancedExpanded = false
@State private var showingProUpsell = false
@State private var minimumTemperatureInput: String = ""
@State private var maximumTemperatureInput: String = ""
@State private var showingAppearanceEditor = false
@EnvironmentObject private var storeKitManager: StoreKitManager
@State private var hasActiveProSubscription = false
let onSave: (BatteryConfiguration) -> Void
private enum TemperatureEditingField {
case minimumTemperature
case maximumTemperature
}
private let voltageSnapValues: [Double] = [6, 12, 12.8, 24, 25.6, 36, 48, 51.2]
private let capacitySnapValues: [Double] = [10, 20, 50, 75, 100, 125, 150, 200, 300, 400, 600, 800, 1000]
private let voltageSnapTolerance: Double = 0.5
private let capacitySnapTolerance: Double = 10.0
private let batteryIconOptions: [String] = [
"battery.100",
"battery.100.bolt",
"battery.75",
"battery.25",
"battery.0",
"bolt",
"bolt.fill",
"bolt.circle",
"bolt.horizontal.circle",
"powerplug",
"car.battery",
"bolt.square",
"lightbulb"
]
private var nameFieldLabel: String {
String(
localized: "battery.editor.field.name",
bundle: .main,
comment: "Label for the battery name text field"
)
}
private var namePlaceholder: String {
String(
localized: "battery.editor.placeholder.name",
bundle: .main,
comment: "Placeholder example for the battery name field"
)
}
private var chemistryLabel: String {
String(
localized: "battery.editor.field.chemistry",
bundle: .main,
comment: "Label describing the chemistry menu"
)
}
private var summaryLabel: String {
String(
localized: "battery.editor.section.summary",
bundle: .main,
comment: "Label for the summary section in the editor"
)
}
private var sliderVoltageTitle: String {
String(
localized: "battery.editor.slider.voltage",
bundle: .main,
comment: "Title for the nominal voltage slider"
)
}
private var sliderCapacityTitle: String {
String(
localized: "battery.editor.slider.capacity",
bundle: .main,
comment: "Title for the capacity slider"
)
}
private var advancedSectionTitle: String {
String(
localized: "battery.editor.section.advanced",
bundle: .main,
comment: "Title for the advanced settings section in the battery editor"
)
}
private var usableCapacitySliderTitle: String {
String(
localized: "battery.editor.slider.usable_capacity",
bundle: .main,
comment: "Title for the usable capacity slider"
)
}
private var resetButtonTitle: String {
String(
localized: "battery.editor.button.reset_default",
bundle: .main,
comment: "Title for the reset button in the advanced section"
)
}
private var alertCancelTitle: String {
NSLocalizedString(
"battery.editor.alert.cancel",
bundle: .main,
value: "Cancel",
comment: "Cancel button title for edit alerts"
)
}
private var alertSaveTitle: String {
NSLocalizedString(
"battery.editor.alert.save",
bundle: .main,
value: "Save",
comment: "Save button title for edit alerts"
)
}
private var voltageAlertTitle: String {
NSLocalizedString(
"battery.editor.alert.voltage.title",
bundle: .main,
value: "Edit Nominal Voltage",
comment: "Title for the voltage edit alert"
)
}
private var voltageAlertPlaceholder: String {
NSLocalizedString(
"battery.editor.alert.voltage.placeholder",
bundle: .main,
value: "Voltage",
comment: "Placeholder for voltage text field"
)
}
private var voltageAlertMessage: String {
NSLocalizedString(
"battery.editor.alert.voltage.message",
bundle: .main,
value: "Enter voltage in volts (V)",
comment: "Message for the voltage edit alert"
)
}
private var capacityAlertTitle: String {
NSLocalizedString(
"battery.editor.alert.capacity.title",
bundle: .main,
value: "Edit Capacity",
comment: "Title for the capacity edit alert"
)
}
private var capacityAlertPlaceholder: String {
NSLocalizedString(
"battery.editor.alert.capacity.placeholder",
bundle: .main,
value: "Capacity",
comment: "Placeholder for capacity text field"
)
}
private var capacityAlertMessage: String {
NSLocalizedString(
"battery.editor.alert.capacity.message",
bundle: .main,
value: "Enter capacity in amp-hours (Ah)",
comment: "Message for the capacity edit alert"
)
}
private var usableCapacityAlertTitle: String {
NSLocalizedString(
"battery.editor.alert.usable_capacity.title",
bundle: .main,
value: "Edit Usable Capacity",
comment: "Title for the usable capacity edit alert"
)
}
private var usableCapacityAlertPlaceholder: String {
NSLocalizedString(
"battery.editor.alert.usable_capacity.placeholder",
bundle: .main,
value: "Usable Capacity (%)",
comment: "Placeholder for the usable capacity text field"
)
}
private var usableCapacityAlertMessage: String {
NSLocalizedString(
"battery.editor.alert.usable_capacity.message",
bundle: .main,
value: "Enter usable capacity as a percentage (%)",
comment: "Message for the usable capacity edit alert"
)
}
private var chargeVoltageAlertTitle: String {
NSLocalizedString(
"battery.editor.alert.charge_voltage.title",
bundle: .main,
value: "Edit Charge Voltage",
comment: "Title for the charge voltage edit alert"
)
}
private var chargeVoltageAlertPlaceholder: String {
NSLocalizedString(
"battery.editor.alert.charge_voltage.placeholder",
bundle: .main,
value: "Charge Voltage",
comment: "Placeholder for the charge voltage text field"
)
}
private var chargeVoltageAlertMessage: String {
NSLocalizedString(
"battery.editor.alert.charge_voltage.message",
bundle: .main,
value: "Enter charge voltage in volts (V)",
comment: "Message for the charge voltage edit alert"
)
}
private var cutOffVoltageAlertTitle: String {
NSLocalizedString(
"battery.editor.alert.cutoff_voltage.title",
bundle: .main,
value: "Edit Cut-off Voltage",
comment: "Title for the cut-off voltage edit alert"
)
}
private var cutOffVoltageAlertPlaceholder: String {
NSLocalizedString(
"battery.editor.alert.cutoff_voltage.placeholder",
bundle: .main,
value: "Cut-off Voltage",
comment: "Placeholder for the cut-off voltage text field"
)
}
private var cutOffVoltageAlertMessage: String {
NSLocalizedString(
"battery.editor.alert.cutoff_voltage.message",
bundle: .main,
value: "Enter cut-off voltage in volts (V)",
comment: "Message for the cut-off voltage edit alert"
)
}
private var chargeVoltageTitle: String {
NSLocalizedString(
"battery.editor.slider.charge_voltage",
bundle: .main,
value: "Charge Voltage",
comment: "Title for the charge voltage slider"
)
}
private var cutOffVoltageTitle: String {
NSLocalizedString(
"battery.editor.slider.cutoff_voltage",
bundle: .main,
value: "Cut-off Voltage",
comment: "Title for the cut-off voltage slider"
)
}
private var temperatureRangeTitle: String {
NSLocalizedString(
"battery.editor.slider.temperature_range",
bundle: .main,
value: "Temperature Range",
comment: "Title for the temperature range editor"
)
}
private var chargeVoltageHelperText: String {
NSLocalizedString(
"battery.editor.advanced.charge_voltage.helper",
bundle: .main,
value: "Set the maximum recommended charging voltage.",
comment: "Helper text explaining charge voltage"
)
}
private var cutOffVoltageHelperText: String {
NSLocalizedString(
"battery.editor.advanced.cutoff_voltage.helper",
bundle: .main,
value: "Set the minimum safe discharge voltage.",
comment: "Helper text explaining cut-off voltage"
)
}
private var temperatureRangeHelperText: String {
NSLocalizedString(
"battery.editor.advanced.temperature_range.helper",
bundle: .main,
value: "Define the recommended operating temperature range.",
comment: "Helper text explaining temperature range"
)
}
private var minimumTemperatureLabel: String {
NSLocalizedString(
"battery.editor.slider.temperature_range.min",
bundle: .main,
value: "Minimum",
comment: "Label for minimum temperature control"
)
}
private var maximumTemperatureLabel: String {
NSLocalizedString(
"battery.editor.slider.temperature_range.max",
bundle: .main,
value: "Maximum",
comment: "Label for maximum temperature control"
)
}
private var usableCapacityDefaultFooterFormat: String {
NSLocalizedString(
"battery.editor.advanced.usable_capacity.footer_default",
bundle: .main,
value: "Default value %@ based on chemistry.",
comment: "Footer text explaining the default usable capacity value"
)
}
private var usableCapacityOverrideFooterFormat: String {
NSLocalizedString(
"battery.editor.advanced.usable_capacity.footer_override",
bundle: .main,
value: "Manual override active. Chemistry default remains %@.",
comment: "Footer text explaining the usable capacity override"
)
}
private var usableCapacityFooterText: String {
let defaultPercentage = formattedPercentage(configuration.defaultUsableCapacityFraction)
if configuration.usableCapacityOverrideFraction != nil {
return String.localizedStringWithFormat(usableCapacityOverrideFooterFormat, defaultPercentage)
} else {
return String.localizedStringWithFormat(usableCapacityDefaultFooterFormat, defaultPercentage)
}
}
private var hasUsableCapacityOverride: Bool {
configuration.usableCapacityOverrideFraction != nil
}
private var summaryVoltageLabel: String {
String(
localized: "battery.bank.badge.voltage",
bundle: .main,
comment: "Label used for voltage values"
)
}
private var summaryCapacityLabel: String {
String(
localized: "battery.bank.badge.capacity",
bundle: .main,
comment: "Label used for capacity values"
)
}
private var summaryEnergyLabel: String {
String(
localized: "battery.bank.badge.energy",
bundle: .main,
comment: "Label used for energy values"
)
}
private var appearanceEditorTitle: String {
NSLocalizedString(
"battery.editor.appearance.title",
bundle: .main,
value: "Battery Appearance",
comment: "Title for the battery appearance editor"
)
}
private var appearanceEditorSubtitle: String {
NSLocalizedString(
"battery.editor.appearance.subtitle",
bundle: .main,
value: "Customize how this battery shows up",
comment: "Subtitle shown in the battery appearance editor preview"
)
}
private var appearanceAccessibilityLabel: String {
NSLocalizedString(
"battery.editor.appearance.accessibility",
bundle: .main,
value: "Edit battery appearance",
comment: "Accessibility label for the battery appearance editor button"
)
}
private var iconColor: Color {
Color.componentColor(named: configuration.colorName)
}
private var displayName: String {
configuration.name.isEmpty ? namePlaceholder : configuration.name
}
private var voltageSliderRange: ClosedRange<Double> {
let lowerBound = max(0, min(6, configuration.nominalVoltage))
let upperBound = max(60, configuration.nominalVoltage)
return lowerBound...upperBound
}
private var capacitySliderRange: ClosedRange<Double> {
let lowerBound = max(0, min(5, configuration.capacityAmpHours))
let upperBound = max(1000, configuration.capacityAmpHours)
return lowerBound...upperBound
}
private var usableCapacitySliderRange: ClosedRange<Double> {
0...100
}
private var usableCapacitySnapValues: [Double] {
[configuration.defaultUsableCapacityFraction * 100]
}
private var chargeVoltageSliderRange: ClosedRange<Double> {
let lowerBound = max(0, min(10, configuration.chargeVoltage))
let upperBound = max(60, configuration.chargeVoltage)
return lowerBound...upperBound
}
private var cutOffVoltageSliderRange: ClosedRange<Double> {
let upperReference = max(configuration.nominalVoltage, configuration.chargeVoltage, 60)
let lowerBound = max(0, min(5, configuration.cutOffVoltage))
let upperBound = max(lowerBound + 1, upperReference)
return lowerBound...upperBound
}
private var minimumTemperatureSliderRange: ClosedRange<Double> {
let upperBound = max(configuration.maximumTemperatureCelsius, -60)
return -60...min(upperBound, 80)
}
private var maximumTemperatureSliderRange: ClosedRange<Double> {
let lowerBound = min(configuration.minimumTemperatureCelsius, 80)
return max(lowerBound, -60)...80
}
private var chargeVoltageSnapValues: [Double] {
[13.8, 14.0, 14.2, 14.4, 14.6, 14.8, 15.0]
}
private var cutOffVoltageSnapValues: [Double] {
[10.0, 10.5, 11.0, 11.5, 12.0]
}
private var temperatureSnapValues: [Double] {
[-40, -20, -10, 0, 25, 40, 50, 60]
}
init(configuration: BatteryConfiguration, onSave: @escaping (BatteryConfiguration) -> Void) {
_configuration = State(initialValue: configuration)
self.onSave = onSave
}
var body: some View {
VStack(spacing: 0) {
headerInfoBar
List {
configurationSection
sliderSection
advancedSection
}
.listStyle(.plain)
.scrollIndicators(.hidden)
.scrollContentBackground(.hidden)
.background(Color.clear)
}
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
navigationTitleView
}
}
.onDisappear {
onSave(configuration)
}
.sheet(isPresented: $showingAppearanceEditor) {
ItemEditorView(
title: appearanceEditorTitle,
nameFieldLabel: nameFieldLabel,
previewSubtitle: appearanceEditorSubtitle,
icons: batteryIconOptions,
name: Binding(
get: { configuration.name },
set: { configuration.name = $0 }
),
iconName: Binding(
get: { configuration.iconName },
set: { configuration.iconName = $0 }
),
colorName: Binding(
get: { configuration.colorName },
set: { configuration.colorName = $0 }
)
)
}
.sheet(isPresented: $showingProUpsell) {
CableProPaywallView(isPresented: $showingProUpsell)
}
.task {
hasActiveProSubscription = storeKitManager.isProUnlocked
}
.onReceive(storeKitManager.$status) { _ in
hasActiveProSubscription = storeKitManager.isProUnlocked
}
.alert(
NSLocalizedString(
"battery.editor.alert.minimum_temperature.title",
bundle: .main,
value: "Edit Minimum Temperature",
comment: "Title for the minimum temperature edit alert"
),
isPresented: Binding(
get: { temperatureEditingField == .minimumTemperature },
set: { isPresented in
if !isPresented {
temperatureEditingField = nil
minimumTemperatureInput = ""
}
}
)
) {
TextField(
NSLocalizedString(
"battery.editor.alert.minimum_temperature.placeholder",
bundle: .main,
value: "Minimum Temperature (°C)",
comment: "Placeholder for the minimum temperature text field"
),
text: $minimumTemperatureInput
)
.keyboardType(.decimalPad)
.onAppear {
if minimumTemperatureInput.isEmpty {
minimumTemperatureInput = formattedEditValue(configuration.minimumTemperatureCelsius)
}
}
.onChange(of: minimumTemperatureInput) { _, newValue in
guard temperatureEditingField == .minimumTemperature, let parsed = parseInput(newValue) else { return }
applyMinimumTemperatureInput(parsed)
}
Button(
NSLocalizedString(
"battery.editor.alert.cancel",
bundle: .main,
value: "Cancel",
comment: "Cancel button title for edit alerts"
),
role: .cancel
) {
temperatureEditingField = nil
minimumTemperatureInput = ""
}
Button(
NSLocalizedString(
"battery.editor.alert.save",
bundle: .main,
value: "Save",
comment: "Save button title for edit alerts"
)
) {
if let parsed = parseInput(minimumTemperatureInput) {
applyMinimumTemperatureInput(parsed)
}
temperatureEditingField = nil
minimumTemperatureInput = ""
}
} message: {
Text(
NSLocalizedString(
"battery.editor.alert.minimum_temperature.message",
bundle: .main,
value: "Enter minimum temperature in degrees Celsius (°C)",
comment: "Message for the minimum temperature edit alert"
)
)
}
.alert(
NSLocalizedString(
"battery.editor.alert.maximum_temperature.title",
bundle: .main,
value: "Edit Maximum Temperature",
comment: "Title for the maximum temperature edit alert"
),
isPresented: Binding(
get: { temperatureEditingField == .maximumTemperature },
set: { isPresented in
if !isPresented {
temperatureEditingField = nil
maximumTemperatureInput = ""
}
}
)
) {
TextField(
NSLocalizedString(
"battery.editor.alert.maximum_temperature.placeholder",
bundle: .main,
value: "Maximum Temperature (°C)",
comment: "Placeholder for the maximum temperature text field"
),
text: $maximumTemperatureInput
)
.keyboardType(.decimalPad)
.onAppear {
if maximumTemperatureInput.isEmpty {
maximumTemperatureInput = formattedEditValue(configuration.maximumTemperatureCelsius)
}
}
.onChange(of: maximumTemperatureInput) { _, newValue in
guard temperatureEditingField == .maximumTemperature, let parsed = parseInput(newValue) else { return }
applyMaximumTemperatureInput(parsed)
}
Button(
NSLocalizedString(
"battery.editor.alert.cancel",
bundle: .main,
value: "Cancel",
comment: "Cancel button title for edit alerts"
),
role: .cancel
) {
temperatureEditingField = nil
maximumTemperatureInput = ""
}
Button(
NSLocalizedString(
"battery.editor.alert.save",
bundle: .main,
value: "Save",
comment: "Save button title for edit alerts"
)
) {
if let parsed = parseInput(maximumTemperatureInput) {
applyMaximumTemperatureInput(parsed)
}
temperatureEditingField = nil
maximumTemperatureInput = ""
}
} message: {
Text(
NSLocalizedString(
"battery.editor.alert.maximum_temperature.message",
bundle: .main,
value: "Enter maximum temperature in degrees Celsius (°C)",
comment: "Message for the maximum temperature edit alert"
)
)
}
}
private var navigationTitleView: some View {
Button {
showingAppearanceEditor = true
} label: {
HStack(spacing: 8) {
LoadIconView(
remoteIconURLString: nil,
fallbackSystemName: configuration.iconName.isEmpty ? "battery.100.bolt" : configuration.iconName,
fallbackColor: iconColor,
size: 26
)
Text(displayName)
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
}
}
.buttonStyle(.plain)
.accessibilityLabel(appearanceAccessibilityLabel)
}
private var configurationSection: some View {
Section {
VStack(alignment: .leading, spacing: 8) {
Text(chemistryLabel.uppercased())
.font(.headline)
Menu {
ForEach(BatteryConfiguration.Chemistry.allCases) { chemistry in
Button {
configuration.chemistry = chemistry
} label: {
if chemistry == configuration.chemistry {
Label(chemistry.displayName, systemImage: "checkmark")
} else {
Text(chemistry.displayName)
}
}
}
} label: {
HStack {
Text(configuration.chemistry.displayName)
.font(.title)
.fontWeight(.bold)
Spacer()
Image(systemName: "chevron.down")
.font(.footnote.weight(.bold))
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
.listRowBackground(Color(.systemBackground))
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
}
private var summarySection: some View {
Section {
VStack(alignment: .leading, spacing: 8) {
Text(summaryLabel.uppercased())
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.secondary)
ViewThatFits(in: .horizontal) {
HStack(spacing: 16) {
summaryBadge(
title: summaryVoltageLabel,
value: formattedValue(configuration.nominalVoltage, unit: "V"),
symbol: "bolt"
)
summaryBadge(
title: summaryCapacityLabel,
value: formattedValue(configuration.capacityAmpHours, unit: "Ah"),
symbol: "gauge.medium"
)
summaryBadge(
title: summaryEnergyLabel,
value: formattedValue(configuration.energyWattHours, unit: "Wh"),
symbol: "battery.100.bolt"
)
}
VStack(spacing: 12) {
summaryBadge(
title: summaryVoltageLabel,
value: formattedValue(configuration.nominalVoltage, unit: "V"),
symbol: "bolt"
)
summaryBadge(
title: summaryCapacityLabel,
value: formattedValue(configuration.capacityAmpHours, unit: "Ah"),
symbol: "gauge.medium"
)
summaryBadge(
title: summaryEnergyLabel,
value: formattedValue(configuration.energyWattHours, unit: "Wh"),
symbol: "battery.100.bolt"
)
}
}
}
.padding(.vertical, 6)
}
.listRowBackground(Color(.systemBackground))
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
}
private var sliderSection: some View {
Section {
EditableSliderRow(
title: sliderVoltageTitle,
unit: "V",
range: voltageSliderRange,
value: Binding(
get: { configuration.nominalVoltage },
set: { configuration.nominalVoltage = $0 }
),
snapValues: voltageSnapValues,
sliderTransform: normalizedVoltage,
alertTransform: roundToTenth,
formatValue: formattedEditValue,
parseInput: parseInput,
alertCopy: EditableSliderRow.AlertCopy(
title: voltageAlertTitle,
placeholder: voltageAlertPlaceholder,
message: voltageAlertMessage,
cancelTitle: alertCancelTitle,
saveTitle: alertSaveTitle
)
)
.listRowSeparator(.hidden)
EditableSliderRow(
title: sliderCapacityTitle,
unit: "Ah",
range: capacitySliderRange,
value: Binding(
get: { configuration.capacityAmpHours },
set: { configuration.capacityAmpHours = $0 }
),
snapValues: capacitySnapValues,
sliderTransform: normalizedCapacity,
alertTransform: roundToTenth,
formatValue: formattedEditValue,
parseInput: parseInput,
alertCopy: EditableSliderRow.AlertCopy(
title: capacityAlertTitle,
placeholder: capacityAlertPlaceholder,
message: capacityAlertMessage,
cancelTitle: alertCancelTitle,
saveTitle: alertSaveTitle
)
)
.listRowSeparator(.hidden)
}
.listRowBackground(Color(.systemBackground))
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
}
private var advancedSection: some View {
let advancedEnabled = unitSettings.isProUnlocked || hasActiveProSubscription
return Section {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isAdvancedExpanded.toggle()
}
} label: {
HStack(spacing: 8) {
Text(advancedSectionTitle.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))
}
advancedControls
.opacity(advancedEnabled ? 1 : 0.35)
.allowsHitTesting(advancedEnabled)
.transition(.opacity.combined(with: .move(edge: .top)))
.listRowSeparator(.hidden)
.listRowBackground(Color(.systemBackground))
}
}
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
.animation(.easeInOut(duration: 0.2), value: isAdvancedExpanded)
}
private var advancedControls: some View {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
EditableSliderRow(
title: usableCapacitySliderTitle,
unit: "%",
range: usableCapacitySliderRange,
value: Binding(
get: { configuration.usableCapacityFraction * 100 },
set: { newValue in
updateUsableCapacityPercent(newValue)
}
),
buttonText: resetButtonTitle,
buttonAction: resetUsableCapacityToDefault,
isButtonVisible: hasUsableCapacityOverride,
snapValues: usableCapacitySnapValues,
sliderTransform: roundToTenth,
alertTransform: roundToTenth,
formatValue: formattedEditValue,
parseInput: parseInput,
alertCopy: EditableSliderRow.AlertCopy(
title: usableCapacityAlertTitle,
placeholder: usableCapacityAlertPlaceholder,
message: usableCapacityAlertMessage,
cancelTitle: alertCancelTitle,
saveTitle: alertSaveTitle
)
)
Text(usableCapacityFooterText)
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
EditableSliderRow(
title: chargeVoltageTitle,
unit: "V",
range: chargeVoltageSliderRange,
value: Binding(
get: { configuration.chargeVoltage },
set: { newValue in
let clamped = max(newValue, configuration.cutOffVoltage)
configuration.chargeVoltage = clamped
}
),
snapValues: chargeVoltageSnapValues,
sliderTransform: normalizedChargeVoltage,
alertTransform: roundToTenth,
formatValue: formattedEditValue,
parseInput: parseInput,
alertCopy: EditableSliderRow.AlertCopy(
title: chargeVoltageAlertTitle,
placeholder: chargeVoltageAlertPlaceholder,
message: chargeVoltageAlertMessage,
cancelTitle: alertCancelTitle,
saveTitle: alertSaveTitle
)
)
Text(chargeVoltageHelperText)
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
EditableSliderRow(
title: cutOffVoltageTitle,
unit: "V",
range: cutOffVoltageSliderRange,
value: Binding(
get: { configuration.cutOffVoltage },
set: { newValue in
let clamped = min(newValue, configuration.chargeVoltage)
configuration.cutOffVoltage = clamped
}
),
snapValues: cutOffVoltageSnapValues,
sliderTransform: normalizedCutOffVoltage,
alertTransform: roundToTenth,
formatValue: formattedEditValue,
parseInput: parseInput,
alertCopy: EditableSliderRow.AlertCopy(
title: cutOffVoltageAlertTitle,
placeholder: cutOffVoltageAlertPlaceholder,
message: cutOffVoltageAlertMessage,
cancelTitle: alertCancelTitle,
saveTitle: alertSaveTitle
)
)
Text(cutOffVoltageHelperText)
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
temperatureRangeRow
Text(temperatureRangeHelperText)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.top, 6)
}
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 temperatureRangeRow: some View {
VStack(alignment: .leading, spacing: 10) {
Text(temperatureRangeTitle)
.font(.headline)
HStack(spacing: 8) {
Button(action: beginMinimumTemperatureEditing) {
Text(formattedTemperature(configuration.minimumTemperatureCelsius))
.font(.title)
.fontWeight(.bold)
.foregroundStyle(.primary)
}
.buttonStyle(.plain)
Text("")
.font(.title2.weight(.semibold))
.foregroundStyle(.secondary)
Button(action: beginMaximumTemperatureEditing) {
Text(formattedTemperature(configuration.maximumTemperatureCelsius))
.font(.title)
.fontWeight(.bold)
.foregroundStyle(.primary)
}
.buttonStyle(.plain)
}
VStack(alignment: .leading, spacing: 12) {
Text(minimumTemperatureLabel.uppercased())
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.secondary)
HStack {
Text(formattedTemperature(minimumTemperatureSliderRange.lowerBound))
.font(.caption)
.foregroundStyle(.secondary)
Slider(
value: Binding(
get: { configuration.minimumTemperatureCelsius },
set: { newValue in
let adjusted = normalizedTemperature(for: newValue)
configuration.minimumTemperatureCelsius = min(adjusted, configuration.maximumTemperatureCelsius)
}
),
in: minimumTemperatureSliderRange
)
Text(formattedTemperature(minimumTemperatureSliderRange.upperBound))
.font(.caption)
.foregroundStyle(.secondary)
}
Text(maximumTemperatureLabel.uppercased())
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.secondary)
HStack {
Text(formattedTemperature(maximumTemperatureSliderRange.lowerBound))
.font(.caption)
.foregroundStyle(.secondary)
Slider(
value: Binding(
get: { configuration.maximumTemperatureCelsius },
set: { newValue in
let adjusted = normalizedTemperature(for: newValue)
configuration.maximumTemperatureCelsius = max(adjusted, configuration.minimumTemperatureCelsius)
}
),
in: maximumTemperatureSliderRange
)
Text(formattedTemperature(maximumTemperatureSliderRange.upperBound))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private var headerInfoBar: some View {
HStack(spacing: 12) {
overviewChip(
icon: "bolt.fill",
title: summaryVoltageLabel.uppercased(),
value: formattedValue(configuration.nominalVoltage, unit: "V"),
tint: .orange
)
overviewChip(
icon: "gauge.medium",
title: summaryCapacityLabel.uppercased(),
value: formattedValue(configuration.capacityAmpHours, unit: "Ah"),
tint: .blue
)
overviewChip(
icon: "battery.100.bolt",
title: summaryEnergyLabel.uppercased(),
value: formattedValue(configuration.energyWattHours, unit: "Wh"),
tint: .purple
)
Spacer(minLength: 0)
}
.padding(.horizontal)
.padding(.vertical, 8)
.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)
}
Text(title)
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(tint.opacity(0.12))
)
}
private func roundToTenth(_ value: Double) -> Double {
max(0, (value * 10).rounded() / 10)
}
private func formattedEditValue(_ value: Double) -> String {
Self.numberFormatter.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.numberFormatter.number(from: trimmed)?.doubleValue {
return number
}
let decimalSeparator = Locale.current.decimalSeparator ?? "."
let altSeparator = decimalSeparator == "." ? "," : "."
let normalized = trimmed.replacingOccurrences(of: altSeparator, with: decimalSeparator)
return Self.numberFormatter.number(from: normalized)?.doubleValue
}
private func beginMinimumTemperatureEditing() {
minimumTemperatureInput = formattedEditValue(configuration.minimumTemperatureCelsius)
temperatureEditingField = .minimumTemperature
}
private func beginMaximumTemperatureEditing() {
maximumTemperatureInput = formattedEditValue(configuration.maximumTemperatureCelsius)
temperatureEditingField = .maximumTemperature
}
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 normalizedVoltage(for value: Double) -> Double {
let rounded = (value * 10).rounded() / 10
if let snapped = nearestValue(to: rounded, in: voltageSnapValues, tolerance: voltageSnapTolerance) {
return snapped
}
return rounded
}
private func normalizedCapacity(for value: Double) -> Double {
let rounded = (value * 10).rounded() / 10
if let snapped = nearestValue(to: rounded, in: capacitySnapValues, tolerance: capacitySnapTolerance) {
return snapped
}
return rounded
}
private func normalizedChargeVoltage(for value: Double) -> Double {
let rounded = (value * 10).rounded() / 10
if let snapped = nearestValue(to: rounded, in: chargeVoltageSnapValues, tolerance: 0.2) {
return snapped
}
return rounded
}
private func normalizedCutOffVoltage(for value: Double) -> Double {
let rounded = (value * 10).rounded() / 10
if let snapped = nearestValue(to: rounded, in: cutOffVoltageSnapValues, tolerance: 0.2) {
return snapped
}
return rounded
}
private func normalizedTemperature(for value: Double) -> Double {
let rounded = (value * 10).rounded() / 10
if let snapped = nearestValue(to: rounded, in: temperatureSnapValues, tolerance: 1.5) {
return snapped
}
return rounded
}
private func updateUsableCapacityPercent(_ percent: Double) {
let clamped = max(0, min(100, percent))
let rounded = (clamped * 10).rounded() / 10
let fraction = max(0, min(1, rounded / 100))
let defaultFraction = configuration.defaultUsableCapacityFraction
if abs(fraction - defaultFraction) < 0.001 {
configuration.usableCapacityOverrideFraction = nil
} else {
configuration.usableCapacityOverrideFraction = fraction
}
}
private func resetUsableCapacityToDefault() {
configuration.usableCapacityOverrideFraction = nil
}
private func applyMinimumTemperatureInput(_ value: Double) {
let normalized = normalizedTemperature(for: value)
configuration.minimumTemperatureCelsius = min(normalized, configuration.maximumTemperatureCelsius)
}
private func applyMaximumTemperatureInput(_ value: Double) {
let normalized = normalizedTemperature(for: value)
configuration.maximumTemperatureCelsius = max(normalized, configuration.minimumTemperatureCelsius)
}
private func summaryBadge(title: String, value: String, symbol: String) -> some View {
VStack(spacing: 4) {
Image(systemName: symbol)
.font(.title3)
.foregroundStyle(Color.accentColor)
Text(value)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
}
private static let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = .current
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
return formatter
}()
private func formattedValue(_ value: Double, unit: String) -> String {
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
return "\(numberString) \(unit)"
}
private func formattedTemperature(_ value: Double) -> String {
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
return "\(numberString)°C"
}
private func formattedPercentage(_ fraction: Double) -> String {
let clamped = max(0, min(1, fraction))
return Self.percentFormatter.string(from: NSNumber(value: clamped)) ?? String(format: "%.0f%%", clamped * 100)
}
private static let percentFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = .current
formatter.numberStyle = .percent
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 0
return formatter
}()
}
#Preview {
let previewSystem = ElectricalSystem(name: "Camper")
return NavigationStack {
BatteryEditorView(
configuration: BatteryConfiguration(
name: "House Bank",
iconName: "battery.100.bolt",
colorName: "green",
system: previewSystem
),
onSave: { _ in }
)
}
.environmentObject(UnitSystemSettings())
}