1319 lines
47 KiB
Swift
1319 lines
47 KiB
Swift
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())
|
||
}
|