From d081a79b59aba5d263aabf109695040fb04c035e Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Tue, 21 Oct 2025 23:00:56 +0200 Subject: [PATCH] better battery editor view --- Cable/BatteriesView.swift | 12 +- Cable/BatteryConfiguration.swift | 16 +- Cable/BatteryEditorView.swift | 210 +++++++++++++++++------- Cable/SavedBattery.swift | 6 + Cable/SystemComponentsPersistence.swift | 4 + 5 files changed, 188 insertions(+), 60 deletions(-) diff --git a/Cable/BatteriesView.swift b/Cable/BatteriesView.swift index 6f5b8dc..d3b005b 100644 --- a/Cable/BatteriesView.swift +++ b/Cable/BatteriesView.swift @@ -255,7 +255,7 @@ struct BatteriesView: View { private func batteryRow(for battery: SavedBattery) -> some View { VStack(alignment: .leading, spacing: 14) { HStack(spacing: 12) { - batteryIcon + batteryIcon(for: battery) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline) { @@ -312,12 +312,12 @@ struct BatteriesView: View { ) } - private var batteryIcon: some View { + private func batteryIcon(for battery: SavedBattery) -> some View { ZStack { RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(colorForName(system.colorName)) + .fill(colorForName(battery.colorName)) .frame(width: 48, height: 48) - Image(systemName: "battery.100.bolt") + Image(systemName: battery.iconName.isEmpty ? "battery.100.bolt" : battery.iconName) .font(.system(size: 22, weight: .semibold)) .foregroundStyle(Color.white) } @@ -589,6 +589,8 @@ private enum BatteriesViewPreviewData { nominalVoltage: 12.8, capacityAmpHours: 200, chemistry: .lithiumIronPhosphate, + iconName: "battery.100.bolt", + colorName: "green", system: system ), SavedBattery( @@ -596,6 +598,8 @@ private enum BatteriesViewPreviewData { nominalVoltage: 12.0, capacityAmpHours: 90, chemistry: .agm, + iconName: "bolt", + colorName: "orange", system: system ) ] diff --git a/Cable/BatteryConfiguration.swift b/Cable/BatteryConfiguration.swift index 369bfe1..180f408 100644 --- a/Cable/BatteryConfiguration.swift +++ b/Cable/BatteryConfiguration.swift @@ -21,6 +21,8 @@ struct BatteryConfiguration: Identifiable, Hashable { var nominalVoltage: Double var capacityAmpHours: Double var chemistry: Chemistry + var iconName: String + var colorName: String var system: ElectricalSystem init( @@ -29,6 +31,8 @@ struct BatteryConfiguration: Identifiable, Hashable { nominalVoltage: Double = 12.8, capacityAmpHours: Double = 100, chemistry: Chemistry = .lithiumIronPhosphate, + iconName: String = "battery.100", + colorName: String = "blue", system: ElectricalSystem ) { self.id = id @@ -36,6 +40,8 @@ struct BatteryConfiguration: Identifiable, Hashable { self.nominalVoltage = nominalVoltage self.capacityAmpHours = capacityAmpHours self.chemistry = chemistry + self.iconName = iconName + self.colorName = colorName self.system = system } @@ -45,6 +51,8 @@ struct BatteryConfiguration: Identifiable, Hashable { self.nominalVoltage = savedBattery.nominalVoltage self.capacityAmpHours = savedBattery.capacityAmpHours self.chemistry = savedBattery.chemistry + self.iconName = savedBattery.iconName + self.colorName = savedBattery.colorName self.system = system } @@ -57,6 +65,8 @@ struct BatteryConfiguration: Identifiable, Hashable { savedBattery.nominalVoltage = nominalVoltage savedBattery.capacityAmpHours = capacityAmpHours savedBattery.chemistry = chemistry + savedBattery.iconName = iconName + savedBattery.colorName = colorName savedBattery.system = system savedBattery.timestamp = Date() } @@ -68,7 +78,9 @@ extension BatteryConfiguration { lhs.name == rhs.name && lhs.nominalVoltage == rhs.nominalVoltage && lhs.capacityAmpHours == rhs.capacityAmpHours && - lhs.chemistry == rhs.chemistry + lhs.chemistry == rhs.chemistry && + lhs.iconName == rhs.iconName && + lhs.colorName == rhs.colorName } func hash(into hasher: inout Hasher) { @@ -77,5 +89,7 @@ extension BatteryConfiguration { hasher.combine(nominalVoltage) hasher.combine(capacityAmpHours) hasher.combine(chemistry) + hasher.combine(iconName) + hasher.combine(colorName) } } diff --git a/Cable/BatteryEditorView.swift b/Cable/BatteryEditorView.swift index 5049847..fd9ff4d 100644 --- a/Cable/BatteryEditorView.swift +++ b/Cable/BatteryEditorView.swift @@ -5,6 +5,7 @@ struct BatteryEditorView: View { @State private var editingField: EditingField? @State private var voltageInput: String = "" @State private var capacityInput: String = "" + @State private var showingAppearanceEditor = false let onSave: (BatteryConfiguration) -> Void private enum EditingField { @@ -16,6 +17,21 @@ struct BatteryEditorView: View { 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( @@ -88,6 +104,41 @@ struct BatteryEditorView: View { 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 { + colorForName(configuration.colorName) + } + + private var displayName: String { + configuration.name.isEmpty ? namePlaceholder : configuration.name + } private var voltageSliderRange: ClosedRange { let lowerBound = max(0, min(6, configuration.nominalVoltage)) @@ -107,27 +158,44 @@ struct BatteryEditorView: View { } var body: some View { - ScrollView { - VStack(spacing: 24) { - headerCard - slidersSection - } - .padding(.vertical, 24) - .padding(.horizontal) + List { + configurationSection + sliderSection } + .listStyle(.plain) + .scrollIndicators(.hidden) + .scrollContentBackground(.hidden) .background(Color(.systemGroupedBackground).ignoresSafeArea()) - .navigationTitle( - NSLocalizedString( - "battery.editor.title", - bundle: .main, - value: "Battery Setup", - comment: "Title for the battery editor" - ) - ) + .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 } + ) + ) + } .alert( NSLocalizedString( "battery.editor.alert.voltage.title", @@ -278,26 +346,32 @@ struct BatteryEditorView: View { } } - private var headerCard: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 8) { - Text(nameFieldLabel) - .font(.caption) - .foregroundStyle(.secondary) - TextField(namePlaceholder, text: $configuration.name) - .textInputAutocapitalization(.words) - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(.secondarySystemBackground)) - ) + 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) - .font(.caption) - .foregroundStyle(.secondary) + Text(chemistryLabel.uppercased()) + .font(.headline) Menu { ForEach(BatteryConfiguration.Chemistry.allCases) { chemistry in Button { @@ -313,25 +387,29 @@ struct BatteryEditorView: View { } label: { HStack { Text(configuration.chemistry.displayName) - .font(.body.weight(.semibold)) + .font(.title) + .fontWeight(.bold) Spacer() Image(systemName: "chevron.down") .font(.footnote.weight(.bold)) .foregroundStyle(.secondary) } - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(.secondarySystemBackground)) - ) } .buttonStyle(.plain) } + .padding(.vertical, 6) + } + .listRowBackground(Color(.systemBackground)) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18)) + } - VStack(alignment: .leading, spacing: 6) { - Text(summaryLabel) - .font(.caption) + 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) { @@ -371,16 +449,15 @@ struct BatteryEditorView: View { } } } + .padding(.vertical, 6) } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(Color(.tertiarySystemBackground)) - ) + .listRowBackground(Color(.systemBackground)) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18)) } - private var slidersSection: some View { - VStack(spacing: 30) { + private var sliderSection: some View { + Section { SliderSection( title: sliderVoltageTitle, value: Binding( @@ -398,6 +475,7 @@ struct BatteryEditorView: View { tapAction: beginVoltageEditing, snapValues: editingField == .voltage ? nil : voltageSnapValues ) + .listRowSeparator(.hidden) SliderSection( title: sliderCapacityTitle, @@ -416,12 +494,10 @@ struct BatteryEditorView: View { tapAction: beginCapacityEditing, snapValues: editingField == .capacity ? nil : capacitySnapValues ) + .listRowSeparator(.hidden) } - .padding() - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(Color(.secondarySystemBackground)) - ) + .listRowBackground(Color(.systemBackground)) + .listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18)) } private func normalizedVoltage(for value: Double) -> Double { @@ -475,6 +551,25 @@ struct BatteryEditorView: View { return abs(closest - value) <= tolerance ? closest : nil } + private func colorForName(_ colorName: String) -> Color { + switch colorName { + case "blue": return .blue + case "green": return .green + case "orange": return .orange + case "red": return .red + case "purple": return .purple + case "yellow": return .yellow + case "pink": return .pink + case "teal": return .teal + case "indigo": return .indigo + case "mint": return .mint + case "cyan": return .cyan + case "brown": return .brown + case "gray": return .gray + default: return .blue + } + } + private func summaryBadge(title: String, value: String, symbol: String) -> some View { VStack(spacing: 4) { Image(systemName: symbol) @@ -516,7 +611,12 @@ struct BatteryEditorView: View { let previewSystem = ElectricalSystem(name: "Camper") return NavigationStack { BatteryEditorView( - configuration: BatteryConfiguration(name: "House Bank", system: previewSystem), + configuration: BatteryConfiguration( + name: "House Bank", + iconName: "battery.100.bolt", + colorName: "green", + system: previewSystem + ), onSave: { _ in } ) } diff --git a/Cable/SavedBattery.swift b/Cable/SavedBattery.swift index 7833333..721b583 100644 --- a/Cable/SavedBattery.swift +++ b/Cable/SavedBattery.swift @@ -8,6 +8,8 @@ class SavedBattery { var nominalVoltage: Double var capacityAmpHours: Double private var chemistryRawValue: String + var iconName: String = "battery.100" + var colorName: String = "blue" var system: ElectricalSystem? var timestamp: Date @@ -17,6 +19,8 @@ class SavedBattery { nominalVoltage: Double = 12.8, capacityAmpHours: Double = 100, chemistry: BatteryConfiguration.Chemistry = .lithiumIronPhosphate, + iconName: String = "battery.100", + colorName: String = "blue", system: ElectricalSystem? = nil, timestamp: Date = Date() ) { @@ -25,6 +29,8 @@ class SavedBattery { self.nominalVoltage = nominalVoltage self.capacityAmpHours = capacityAmpHours self.chemistryRawValue = chemistry.rawValue + self.iconName = iconName + self.colorName = colorName self.system = system self.timestamp = timestamp } diff --git a/Cable/SystemComponentsPersistence.swift b/Cable/SystemComponentsPersistence.swift index b0972d3..4919e2c 100644 --- a/Cable/SystemComponentsPersistence.swift +++ b/Cable/SystemComponentsPersistence.swift @@ -100,6 +100,8 @@ struct SystemComponentsPersistence { ) return BatteryConfiguration( name: batteryName, + iconName: "battery.100.bolt", + colorName: system.colorName, system: system ) } @@ -119,6 +121,8 @@ struct SystemComponentsPersistence { nominalVoltage: configuration.nominalVoltage, capacityAmpHours: configuration.capacityAmpHours, chemistry: configuration.chemistry, + iconName: configuration.iconName, + colorName: configuration.colorName, system: system ) context.insert(newBattery)