615 lines
21 KiB
Swift
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)
|
|
)
|
|
}
|