Cross-platform refinements to appearance/battery/charger editors, tabs and navigation, plus persistence, screenshot previews and CLAUDE.md docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
402 lines
12 KiB
Swift
402 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
struct ChargersView: View {
|
|
@Binding var editMode: EditMode
|
|
let system: ElectricalSystem
|
|
let chargers: [SavedCharger]
|
|
let onAdd: () -> Void
|
|
let onEdit: (SavedCharger) -> Void
|
|
let onDelete: (IndexSet) -> Void
|
|
let onBrowseLibrary: () -> Void
|
|
|
|
private struct SummaryMetric: Identifiable {
|
|
let id: String
|
|
let icon: String
|
|
let label: String
|
|
let value: String
|
|
let tint: Color
|
|
}
|
|
|
|
private var summaryTitle: String {
|
|
String(
|
|
localized: "chargers.summary.title",
|
|
bundle: .main,
|
|
comment: "Title for the chargers summary section"
|
|
)
|
|
}
|
|
|
|
private var summaryCountLabel: String {
|
|
String(
|
|
localized: "chargers.summary.metric.count",
|
|
bundle: .main,
|
|
comment: "Label for number of chargers metric"
|
|
)
|
|
}
|
|
|
|
private var summaryOutputLabel: String {
|
|
String(
|
|
localized: "chargers.summary.metric.output",
|
|
bundle: .main,
|
|
comment: "Label for output voltage metric"
|
|
)
|
|
}
|
|
|
|
private var summaryCurrentLabel: String {
|
|
String(
|
|
localized: "chargers.summary.metric.current",
|
|
bundle: .main,
|
|
comment: "Label for combined current metric"
|
|
)
|
|
}
|
|
|
|
private var summaryPowerLabel: String {
|
|
String(
|
|
localized: "chargers.summary.metric.power",
|
|
bundle: .main,
|
|
comment: "Label for combined power metric"
|
|
)
|
|
}
|
|
|
|
private var badgeInputLabel: String {
|
|
String(
|
|
localized: "chargers.badge.input",
|
|
bundle: .main,
|
|
comment: "Label for input voltage badge"
|
|
)
|
|
}
|
|
|
|
private var badgeOutputLabel: String {
|
|
String(
|
|
localized: "chargers.badge.output",
|
|
bundle: .main,
|
|
comment: "Label for output voltage badge"
|
|
)
|
|
}
|
|
|
|
private var badgeCurrentLabel: String {
|
|
String(
|
|
localized: "chargers.badge.current",
|
|
bundle: .main,
|
|
comment: "Label for charging current badge"
|
|
)
|
|
}
|
|
|
|
private var badgePowerLabel: String {
|
|
String(
|
|
localized: "chargers.badge.power",
|
|
bundle: .main,
|
|
comment: "Label for charging power badge"
|
|
)
|
|
}
|
|
|
|
init(
|
|
system: ElectricalSystem,
|
|
chargers: [SavedCharger],
|
|
editMode: Binding<EditMode> = .constant(.inactive),
|
|
onAdd: @escaping () -> Void = {},
|
|
onEdit: @escaping (SavedCharger) -> Void = { _ in },
|
|
onDelete: @escaping (IndexSet) -> Void = { _ in },
|
|
onBrowseLibrary: @escaping () -> Void = {}
|
|
) {
|
|
self.system = system
|
|
self.chargers = chargers
|
|
self.onAdd = onAdd
|
|
self.onEdit = onEdit
|
|
self.onDelete = onDelete
|
|
self.onBrowseLibrary = onBrowseLibrary
|
|
_editMode = editMode
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
if chargers.isEmpty {
|
|
emptyState
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
chargersListWithHeader
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color(.systemGroupedBackground))
|
|
}
|
|
|
|
private var chargerStatsHeader: some View {
|
|
StatsHeaderContainer {
|
|
chargerSummaryContent
|
|
}
|
|
}
|
|
|
|
private var chargerSummaryContent: some View {
|
|
VStack(spacing: 0) {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text(summaryTitle)
|
|
.font(.headline.weight(.semibold))
|
|
Spacer()
|
|
}
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 16) {
|
|
ForEach(summaryMetrics) { metric in
|
|
ComponentSummaryMetricView(
|
|
icon: metric.icon,
|
|
label: metric.label,
|
|
value: metric.value,
|
|
tint: metric.tint
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal, 2)
|
|
}
|
|
.scrollClipDisabled(false)
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var chargersListWithHeader: some View {
|
|
if #available(iOS 26.0, *) {
|
|
baseChargersList
|
|
.scrollEdgeEffectStyle(.soft, for: .top)
|
|
.safeAreaInset(edge: .top, spacing: 0) {
|
|
chargerStatsHeader
|
|
}
|
|
} else {
|
|
baseChargersList
|
|
.safeAreaInset(edge: .top, spacing: 0) {
|
|
chargerStatsHeader
|
|
}
|
|
}
|
|
}
|
|
|
|
private var baseChargersList: some View {
|
|
List {
|
|
ForEach(chargers) { charger in
|
|
Button {
|
|
onEdit(charger)
|
|
} label: {
|
|
chargerRow(for: charger)
|
|
}
|
|
.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)
|
|
.accessibilityIdentifier("chargers-list")
|
|
}
|
|
|
|
private var summaryMetrics: [SummaryMetric] {
|
|
guard !chargers.isEmpty else { return [] }
|
|
|
|
var metrics: [SummaryMetric] = [
|
|
SummaryMetric(
|
|
id: "count",
|
|
icon: "bolt.fill",
|
|
label: summaryCountLabel,
|
|
value: "\(chargers.count)",
|
|
tint: .blue
|
|
)
|
|
]
|
|
|
|
if let output = representativeOutputVoltage {
|
|
metrics.append(
|
|
SummaryMetric(
|
|
id: "output",
|
|
icon: "battery.100.bolt",
|
|
label: summaryOutputLabel,
|
|
value: formattedVoltage(output),
|
|
tint: .green
|
|
)
|
|
)
|
|
}
|
|
|
|
if totalCurrent > 0 {
|
|
metrics.append(
|
|
SummaryMetric(
|
|
id: "current",
|
|
icon: "gauge",
|
|
label: summaryCurrentLabel,
|
|
value: formattedCurrent(totalCurrent),
|
|
tint: .orange
|
|
)
|
|
)
|
|
}
|
|
|
|
if totalPower > 0 {
|
|
metrics.append(
|
|
SummaryMetric(
|
|
id: "power",
|
|
icon: "bolt.badge.a",
|
|
label: summaryPowerLabel,
|
|
value: formattedPower(totalPower),
|
|
tint: .pink
|
|
)
|
|
)
|
|
}
|
|
|
|
return metrics
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
OnboardingInfoView(
|
|
configuration: .charger(),
|
|
onPrimaryAction: onAdd,
|
|
onSecondaryAction: onBrowseLibrary
|
|
)
|
|
.padding(.horizontal, 0)
|
|
}
|
|
|
|
private func chargerRow(for charger: SavedCharger) -> some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack(spacing: 12) {
|
|
LoadIconView(
|
|
remoteIconURLString: charger.remoteIconURLString,
|
|
fallbackSystemName: charger.iconName,
|
|
fallbackColor: Color.componentColor(named: charger.colorName),
|
|
size: 48
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(charger.name)
|
|
.font(.body.weight(.medium))
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
|
|
Text(chargerSummary(for: charger))
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule(style: .continuous)
|
|
.fill(Color(.tertiarySystemBackground))
|
|
)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if editMode == .inactive {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
metricsSection(for: charger)
|
|
}
|
|
.padding(.vertical, 16)
|
|
.padding(.horizontal, 16)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.fill(Color(.systemBackground))
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func metricsSection(for charger: SavedCharger) -> some View {
|
|
let badges: [(String, String, Color)] = [
|
|
(badgeInputLabel, formattedVoltage(charger.inputVoltage), .indigo),
|
|
(badgeOutputLabel, formattedVoltage(charger.outputVoltage), .green),
|
|
(badgeCurrentLabel, formattedCurrent(charger.maxCurrentAmps), .orange),
|
|
(badgePowerLabel, formattedPower(charger.effectivePowerWatts), .pink)
|
|
]
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 12) {
|
|
ForEach(badges, id: \.0) { label, value, tint in
|
|
ComponentMetricBadgeView(label: label, value: value, tint: tint)
|
|
}
|
|
}
|
|
.padding(.trailing, 16)
|
|
}
|
|
.scrollClipDisabled(true)
|
|
}
|
|
|
|
private func chargerSummary(for charger: SavedCharger) -> String {
|
|
let inputText = formattedVoltage(charger.inputVoltage)
|
|
let outputText = formattedVoltage(charger.outputVoltage)
|
|
let currentText = formattedCurrent(charger.maxCurrentAmps)
|
|
return [inputText, outputText, currentText].joined(separator: " • ")
|
|
}
|
|
|
|
private var totalCurrent: Double {
|
|
chargers.reduce(0) { result, charger in
|
|
result + max(0, charger.maxCurrentAmps)
|
|
}
|
|
}
|
|
|
|
private var totalPower: Double {
|
|
chargers.reduce(0) { result, charger in
|
|
result + max(0, charger.effectivePowerWatts)
|
|
}
|
|
}
|
|
|
|
private var representativeOutputVoltage: Double? {
|
|
let outputs = chargers.map { $0.outputVoltage }.filter { $0 > 0 }
|
|
guard !outputs.isEmpty else { return nil }
|
|
let total = outputs.reduce(0, +)
|
|
return total / Double(outputs.count)
|
|
}
|
|
|
|
private func formattedVoltage(_ value: Double) -> String {
|
|
guard value > 0 else { return "—" }
|
|
return String(format: "%.1fV", value)
|
|
}
|
|
|
|
private func formattedCurrent(_ value: Double) -> String {
|
|
guard value > 0 else { return "—" }
|
|
return String(format: "%.1fA", value)
|
|
}
|
|
|
|
private func formattedPower(_ value: Double) -> String {
|
|
guard value > 0 else { return "—" }
|
|
return String(format: "%.0fW", value)
|
|
}
|
|
}
|
|
|
|
private enum ChargersViewPreviewData {
|
|
static let system = ElectricalSystem(name: "Preview System", iconName: "bolt", colorName: "teal")
|
|
static let chargers: [SavedCharger] = {
|
|
let shore = SavedCharger(
|
|
name: "Shore Power",
|
|
inputVoltage: 230,
|
|
outputVoltage: 14.4,
|
|
maxCurrentAmps: 40,
|
|
maxPowerWatts: 580,
|
|
iconName: "powerplug",
|
|
colorName: "orange",
|
|
system: system
|
|
)
|
|
shore.timestamp = Date(timeIntervalSinceReferenceDate: 2000)
|
|
|
|
let dcDc = SavedCharger(
|
|
name: "DC-DC Charger",
|
|
inputVoltage: 12.8,
|
|
outputVoltage: 14.2,
|
|
maxCurrentAmps: 30,
|
|
maxPowerWatts: 0,
|
|
iconName: "bolt.badge.clock",
|
|
colorName: "blue",
|
|
system: system
|
|
)
|
|
dcDc.timestamp = Date(timeIntervalSinceReferenceDate: 2100)
|
|
|
|
return [shore, dcDc]
|
|
}()
|
|
}
|
|
|
|
#Preview {
|
|
ChargersView(
|
|
system: ChargersViewPreviewData.system,
|
|
chargers: ChargersViewPreviewData.chargers,
|
|
editMode: .constant(.inactive)
|
|
)
|
|
}
|