Files
Cable/Cable/BatteriesView.swift
Stefan Lange-Hegermann d081a79b59 better battery editor view
2025-10-21 23:00:56 +02:00

615 lines
21 KiB
Swift

import SwiftUI
struct BatteriesView: View {
@Binding var editMode: EditMode
let system: ElectricalSystem
let batteries: [SavedBattery]
let onEdit: (SavedBattery) -> Void
let onDelete: (IndexSet) -> Void
private enum BankStatus: Identifiable {
case voltage(target: Double, mismatchedCount: Int)
case capacity(target: Double, mismatchedCount: Int)
var id: String {
switch self {
case .voltage: return "voltage"
case .capacity: return "capacity"
}
}
var symbol: String {
switch self {
case .voltage: return "exclamationmark.triangle.fill"
case .capacity: return "exclamationmark.circle.fill"
}
}
var tint: Color {
switch self {
case .voltage: return .red
case .capacity: return .orange
}
}
var bannerText: String {
switch self {
case .voltage:
return String(
localized: "battery.bank.banner.voltage",
bundle: .main,
comment: "Short banner text describing a voltage mismatch"
)
case .capacity:
return String(
localized: "battery.bank.banner.capacity",
bundle: .main,
comment: "Short banner text describing a capacity mismatch"
)
}
}
}
private let voltageTolerance: Double = 0.05
private let capacityTolerance: Double = 0.5
@State private var activeStatus: BankStatus?
private var bankTitle: String {
String(
localized: "battery.bank.header.title",
bundle: .main,
comment: "Title for the battery bank summary section"
)
}
private var metricCountLabel: String {
String(
localized: "battery.bank.metric.count",
bundle: .main,
comment: "Label for number of batteries metric"
)
}
private var metricCapacityLabel: String {
String(
localized: "battery.bank.metric.capacity",
bundle: .main,
comment: "Label for total capacity metric"
)
}
private var metricEnergyLabel: String {
String(
localized: "battery.bank.metric.energy",
bundle: .main,
comment: "Label for total energy metric"
)
}
private var badgeVoltageLabel: String {
String(
localized: "battery.bank.badge.voltage",
bundle: .main,
comment: "Label for voltage badge"
)
}
private var badgeCapacityLabel: String {
String(
localized: "battery.bank.badge.capacity",
bundle: .main,
comment: "Label for capacity badge"
)
}
private var badgeEnergyLabel: String {
String(
localized: "battery.bank.badge.energy",
bundle: .main,
comment: "Label for energy badge"
)
}
private var emptyTitle: String {
String(
localized: "battery.bank.empty.title",
bundle: .main,
comment: "Title shown when no batteries are configured"
)
}
init(
system: ElectricalSystem,
batteries: [SavedBattery],
editMode: Binding<EditMode> = .constant(.inactive),
onEdit: @escaping (SavedBattery) -> Void = { _ in },
onDelete: @escaping (IndexSet) -> Void = { _ in }
) {
self.system = system
self.batteries = batteries
self.onEdit = onEdit
self.onDelete = onDelete
self._editMode = editMode
}
var body: some View {
VStack(spacing: 0) {
if batteries.isEmpty {
emptyState
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
summarySection
List {
ForEach(batteries) { battery in
Button {
onEdit(battery)
} label: {
batteryRow(for: battery)
}
.buttonStyle(.plain)
.disabled(editMode == .active)
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onDelete(perform: onDelete)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.environment(\.editMode, $editMode)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.alert(item: $activeStatus) { status in
let detail = detailInfo(for: status)
return Alert(
title: Text(detail.title),
message: Text(detail.message),
dismissButton: .default(
Text(
NSLocalizedString(
"battery.bank.status.dismiss",
bundle: .main,
value: "Got it",
comment: "Dismiss button title for battery bank status alert"
)
)
)
)
}
}
private var summarySection: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(bankTitle)
.font(.headline.weight(.semibold))
Spacer()
}
ViewThatFits(in: .horizontal) {
HStack(spacing: 16) {
summaryMetric(
icon: "battery.100",
label: metricCountLabel,
value: "\(batteries.count)",
tint: .blue
)
summaryMetric(
icon: "gauge.medium",
label: metricCapacityLabel,
value: formattedValue(totalCapacity, unit: "Ah"),
tint: .orange
)
summaryMetric(
icon: "bolt.circle",
label: metricEnergyLabel,
value: formattedValue(totalEnergy, unit: "Wh"),
tint: .green
)
}
VStack(alignment: .leading, spacing: 12) {
summaryMetric(
icon: "battery.100",
label: metricCountLabel,
value: "\(batteries.count)",
tint: .blue
)
summaryMetric(
icon: "gauge.medium",
label: metricCapacityLabel,
value: formattedValue(totalCapacity, unit: "Ah"),
tint: .orange
)
summaryMetric(
icon: "bolt.circle",
label: metricEnergyLabel,
value: formattedValue(totalEnergy, unit: "Wh"),
tint: .green
)
}
}
if let status = bankStatus {
Button {
activeStatus = status
} label: {
statusBanner(for: status)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color(.systemGroupedBackground))
Divider()
.background(Color(.separator))
}
}
private func batteryRow(for battery: SavedBattery) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
batteryIcon(for: battery)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
Text(battery.name)
.font(.body.weight(.medium))
.lineLimit(1)
.truncationMode(.tail)
Spacer()
Text(formattedValue(battery.nominalVoltage, unit: "V"))
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text(battery.chemistry.displayName)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule(style: .continuous)
.fill(Color(.tertiarySystemBackground))
)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
metricBadge(
label: badgeVoltageLabel,
value: formattedValue(battery.nominalVoltage, unit: "V"),
tint: .orange
)
metricBadge(
label: badgeCapacityLabel,
value: formattedValue(battery.capacityAmpHours, unit: "Ah"),
tint: .blue
)
metricBadge(
label: badgeEnergyLabel,
value: formattedValue(battery.energyWattHours, unit: "Wh"),
tint: .green
)
Spacer()
}
}
.padding(.vertical, 16)
.padding(.horizontal, 16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(.systemBackground))
)
}
private func batteryIcon(for battery: SavedBattery) -> some View {
ZStack {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(colorForName(battery.colorName))
.frame(width: 48, height: 48)
Image(systemName: battery.iconName.isEmpty ? "battery.100.bolt" : battery.iconName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.white)
}
}
private var totalCapacity: Double {
batteries.reduce(0) { result, battery in
result + battery.capacityAmpHours
}
}
private var totalEnergy: Double {
batteries.reduce(0) { result, battery in
result + battery.energyWattHours
}
}
private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint)
Text(value)
.font(.body.weight(.semibold))
}
Text(label.uppercased())
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.secondary)
}
}
private func metricBadge(label: String, value: String, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label.uppercased())
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(tint)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(tint.opacity(0.12))
)
}
private func statusBanner(for status: BankStatus) -> some View {
HStack(spacing: 10) {
Image(systemName: status.symbol)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(status.tint)
Text(status.bannerText)
.font(.subheadline.weight(.semibold))
.foregroundStyle(status.tint)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(status.tint.opacity(0.12))
)
}
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 var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "battery.100")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text(emptyTitle)
.font(.title3)
.fontWeight(.semibold)
Text(emptySubtitle(for: system.name))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
private static let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = .current
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 var dominantVoltage: Double? {
guard batteries.count > 1 else { return nil }
return dominantValue(
from: batteries.map { $0.nominalVoltage },
scale: 0.1
)
}
private var dominantCapacity: Double? {
guard batteries.count > 1 else { return nil }
return dominantValue(
from: batteries.map { $0.capacityAmpHours },
scale: 1.0
)
}
private func dominantValue(from values: [Double], scale: Double) -> Double? {
guard !values.isEmpty else { return nil }
var counts: [Double: Int] = [:]
var bestKey: Double?
var bestCount = 0
for value in values {
let key = (value / scale).rounded() * scale
let newCount = (counts[key] ?? 0) + 1
counts[key] = newCount
if newCount > bestCount {
bestCount = newCount
bestKey = key
}
}
return bestKey
}
private var bankStatus: BankStatus? {
guard batteries.count > 1 else { return nil }
if let targetVoltage = dominantVoltage {
let mismatched = batteries.filter { abs($0.nominalVoltage - targetVoltage) > voltageTolerance }
if !mismatched.isEmpty {
return .voltage(target: targetVoltage, mismatchedCount: mismatched.count)
}
}
if let targetCapacity = dominantCapacity {
let mismatched = batteries.filter { abs($0.capacityAmpHours - targetCapacity) > capacityTolerance }
if !mismatched.isEmpty {
return .capacity(target: targetCapacity, mismatchedCount: mismatched.count)
}
}
return nil
}
private func emptySubtitle(for systemName: String) -> String {
let format = NSLocalizedString(
"battery.bank.empty.subtitle",
tableName: nil,
bundle: .main,
value: "Tap the plus button to configure a battery for %@.",
comment: "Subtitle shown when no batteries are configured"
)
return String(format: format, systemName)
}
private func detailInfo(for status: BankStatus) -> (title: String, message: String) {
switch status {
case let .voltage(target, mismatchedCount):
let countText = mismatchedCount == 1
? NSLocalizedString(
"battery.bank.status.single.battery",
bundle: .main,
value: "One battery",
comment: "Singular form describing mismatched battery count"
)
: String(
format: NSLocalizedString(
"battery.bank.status.multiple.batteries",
bundle: .main,
value: "%d batteries",
comment: "Plural form describing mismatched battery count"
),
mismatchedCount
)
let expected = formattedValue(target, unit: "V")
let message = String(
format: NSLocalizedString(
"battery.bank.status.voltage.message",
bundle: .main,
value: "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters.",
comment: "Explanation for voltage mismatch in battery bank"
),
countText,
expected
)
return (
NSLocalizedString(
"battery.bank.status.voltage.title",
bundle: .main,
value: "Voltage mismatch",
comment: "Title for voltage mismatch alert"
),
message
)
case let .capacity(target, mismatchedCount):
let countText = mismatchedCount == 1
? NSLocalizedString(
"battery.bank.status.single.battery",
bundle: .main,
value: "One battery",
comment: "Singular form describing mismatched battery count"
)
: String(
format: NSLocalizedString(
"battery.bank.status.multiple.batteries",
bundle: .main,
value: "%d batteries",
comment: "Plural form describing mismatched battery count"
),
mismatchedCount
)
let expected = formattedValue(target, unit: "Ah")
let message = String(
format: NSLocalizedString(
"battery.bank.status.capacity.message",
bundle: .main,
value: "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear.",
comment: "Explanation for capacity mismatch in battery bank"
),
countText,
expected
)
return (
NSLocalizedString(
"battery.bank.status.capacity.title",
bundle: .main,
value: "Capacity mismatch",
comment: "Title for capacity mismatch alert"
),
message
)
}
}
}
private enum BatteriesViewPreviewData {
static let system = ElectricalSystem(name: "Preview System", iconName: "bolt", colorName: "green")
static let batteries: [SavedBattery] = [
SavedBattery(
name: "House Bank",
nominalVoltage: 12.8,
capacityAmpHours: 200,
chemistry: .lithiumIronPhosphate,
iconName: "battery.100.bolt",
colorName: "green",
system: system
),
SavedBattery(
name: "Starter Battery",
nominalVoltage: 12.0,
capacityAmpHours: 90,
chemistry: .agm,
iconName: "bolt",
colorName: "orange",
system: system
)
]
}
#Preview {
BatteriesView(
system: BatteriesViewPreviewData.system,
batteries: BatteriesViewPreviewData.batteries,
editMode: .constant(.inactive)
)
}