diff --git a/Cable.xcodeproj/project.pbxproj b/Cable.xcodeproj/project.pbxproj index 3266118..70baabb 100644 --- a/Cable.xcodeproj/project.pbxproj +++ b/Cable.xcodeproj/project.pbxproj @@ -405,7 +405,7 @@ CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 28; DEVELOPMENT_TEAM = RE4FXQ754N; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; @@ -422,7 +422,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -440,7 +440,7 @@ CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 28; DEVELOPMENT_TEAM = RE4FXQ754N; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; @@ -457,7 +457,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Cable/Assets.xcassets/battery-onboarding.imageset/Contents.json b/Cable/Assets.xcassets/battery-onboarding.imageset/Contents.json new file mode 100644 index 0000000..24c4d28 --- /dev/null +++ b/Cable/Assets.xcassets/battery-onboarding.imageset/Contents.json @@ -0,0 +1,52 @@ +{ + "images" : [ + { + "filename" : "battery-light.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "battery-dark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Cable/Assets.xcassets/battery-onboarding.imageset/battery-dark.png b/Cable/Assets.xcassets/battery-onboarding.imageset/battery-dark.png new file mode 100644 index 0000000..a34076d Binary files /dev/null and b/Cable/Assets.xcassets/battery-onboarding.imageset/battery-dark.png differ diff --git a/Cable/Assets.xcassets/battery-onboarding.imageset/battery-light.png b/Cable/Assets.xcassets/battery-onboarding.imageset/battery-light.png new file mode 100644 index 0000000..9e3be87 Binary files /dev/null and b/Cable/Assets.xcassets/battery-onboarding.imageset/battery-light.png differ diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index 8d7501a..5d91a6b 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -141,5 +141,38 @@ "battery.editor.alert.save" = "Save"; "battery.editor.default_name" = "New Battery"; +"charger.editor.title" = "Charger"; +"charger.editor.field.name" = "Name"; +"charger.editor.placeholder.name" = "Workshop Charger"; +"charger.editor.section.electrical" = "Electrical"; +"charger.editor.section.power" = "Charge Output"; +"charger.editor.appearance.title" = "Charger Appearance"; +"charger.editor.appearance.subtitle" = "Customize how this charger shows up"; +"charger.editor.appearance.accessibility" = "Edit charger appearance"; +"charger.editor.field.input_voltage" = "Input Voltage"; +"charger.editor.field.output_voltage" = "Output Voltage"; +"charger.editor.field.current" = "Charge Current"; +"charger.editor.field.power" = "Charge Power"; +"charger.editor.field.power.footer" = "Leave blank when the rated wattage isn't published. We'll calculate it from voltage and current."; +"charger.editor.default_name" = "New Charger"; +"charger.default.new" = "New Charger"; + +"chargers.summary.title" = "Charging Overview"; +"chargers.summary.metric.count" = "Chargers"; +"chargers.summary.metric.output" = "Output Voltage"; +"chargers.summary.metric.current" = "Charge Rate"; +"chargers.summary.metric.power" = "Charge Power"; +"chargers.badge.input" = "Input"; +"chargers.badge.output" = "Output"; +"chargers.badge.current" = "Current"; +"chargers.badge.power" = "Power"; +"chargers.onboarding.title" = "Add your chargers"; +"chargers.onboarding.subtitle" = "Track shore power supplies, alternator chargers, and solar controllers to understand your charging capacity."; +"chargers.onboarding.primary" = "Create Charger"; + +"sample.charger.shore.name" = "Shore power charger"; +"sample.charger.dcdc.name" = "DC-DC charger"; +"sample.charger.workbench.name" = "Workbench charger"; + "chargers.title" = "Chargers for %@"; "chargers.subtitle" = "Charger components will be available soon."; diff --git a/Cable/Batteries/BatteriesView.swift b/Cable/Batteries/BatteriesView.swift index d3b005b..ca0425f 100644 --- a/Cable/Batteries/BatteriesView.swift +++ b/Cable/Batteries/BatteriesView.swift @@ -315,7 +315,7 @@ struct BatteriesView: View { private func batteryIcon(for battery: SavedBattery) -> some View { ZStack { RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(colorForName(battery.colorName)) + .fill(Color.componentColor(named: battery.colorName)) .frame(width: 48, height: 48) Image(systemName: battery.iconName.isEmpty ? "battery.100.bolt" : battery.iconName) .font(.system(size: 22, weight: .semibold)) @@ -336,36 +336,19 @@ struct BatteriesView: View { } 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) - } + ComponentSummaryMetricView( + icon: icon, + label: label, + value: value, + tint: tint + ) } 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)) + ComponentMetricBadgeView( + label: label, + value: value, + tint: tint ) } @@ -387,25 +370,6 @@ struct BatteriesView: View { ) } - 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") diff --git a/Cable/Batteries/BatteryEditorView.swift b/Cable/Batteries/BatteryEditorView.swift index 7ff8296..dad035f 100644 --- a/Cable/Batteries/BatteryEditorView.swift +++ b/Cable/Batteries/BatteryEditorView.swift @@ -133,7 +133,7 @@ struct BatteryEditorView: View { } private var iconColor: Color { - colorForName(configuration.colorName) + Color.componentColor(named: configuration.colorName) } private var displayName: String { @@ -608,25 +608,6 @@ 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) diff --git a/Cable/CableApp.swift b/Cable/CableApp.swift index ca21175..969d6df 100644 --- a/Cable/CableApp.swift +++ b/Cable/CableApp.swift @@ -15,13 +15,13 @@ struct CableApp: App { var sharedModelContainer: ModelContainer = { do { // Try the simple approach first - return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, Item.self) + return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self) } catch { print("Failed to create ModelContainer with simple approach: \(error)") // Try in-memory as fallback do { - let schema = Schema([ElectricalSystem.self, SavedLoad.self, SavedBattery.self, Item.self]) + let schema = Schema([ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) return try ModelContainer(for: schema, configurations: [modelConfiguration]) } catch { diff --git a/Cable/Chargers/ChargerConfiguration.swift b/Cable/Chargers/ChargerConfiguration.swift new file mode 100644 index 0000000..4004109 --- /dev/null +++ b/Cable/Chargers/ChargerConfiguration.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftData + +struct ChargerConfiguration: Identifiable, Hashable { + let id: UUID + var name: String + var inputVoltage: Double + var outputVoltage: Double + var maxCurrentAmps: Double + var maxPowerWatts: Double + var iconName: String + var colorName: String + var system: ElectricalSystem + + init( + id: UUID = UUID(), + name: String, + inputVoltage: Double = 230.0, + outputVoltage: Double = 14.2, + maxCurrentAmps: Double = 30.0, + maxPowerWatts: Double = 0.0, + iconName: String = "bolt.fill", + colorName: String = "orange", + system: ElectricalSystem + ) { + self.id = id + self.name = name + self.inputVoltage = inputVoltage + self.outputVoltage = outputVoltage + self.maxCurrentAmps = maxCurrentAmps + self.maxPowerWatts = maxPowerWatts + self.iconName = iconName + self.colorName = colorName + self.system = system + } + + init(savedCharger: SavedCharger, system: ElectricalSystem) { + self.id = savedCharger.id + self.name = savedCharger.name + self.inputVoltage = savedCharger.inputVoltage + self.outputVoltage = savedCharger.outputVoltage + self.maxCurrentAmps = savedCharger.maxCurrentAmps + self.maxPowerWatts = savedCharger.maxPowerWatts + self.iconName = savedCharger.iconName + self.colorName = savedCharger.colorName + self.system = system + } + + var effectivePowerWatts: Double { + if maxPowerWatts > 0 { + return maxPowerWatts + } + return outputVoltage * maxCurrentAmps + } + + func apply(to savedCharger: SavedCharger) { + savedCharger.name = name + savedCharger.inputVoltage = inputVoltage + savedCharger.outputVoltage = outputVoltage + savedCharger.maxCurrentAmps = maxCurrentAmps + savedCharger.maxPowerWatts = maxPowerWatts + savedCharger.iconName = iconName + savedCharger.colorName = colorName + savedCharger.system = system + savedCharger.timestamp = Date() + } +} diff --git a/Cable/Chargers/ChargerEditorView.swift b/Cable/Chargers/ChargerEditorView.swift new file mode 100644 index 0000000..eac5d17 --- /dev/null +++ b/Cable/Chargers/ChargerEditorView.swift @@ -0,0 +1,346 @@ +import SwiftUI + +struct ChargerEditorView: View { + @State private var configuration: ChargerConfiguration + @State private var showingAppearanceEditor = false + + let onSave: (ChargerConfiguration) -> Void + + private let chargerIconOptions: [String] = [ + "bolt.fill", + "bolt.circle", + "bolt.square", + "bolt.badge.clock", + "bolt.horizontal", + "powerplug", + "battery.100.bolt", + "car.battery", + "engine.combustion", + "fanblades", + "generator" + ] + + private var editorTitle: String { + NSLocalizedString( + "charger.editor.title", + bundle: .main, + value: "Charger", + comment: "Navigation bar title for the charger editor" + ) + } + + private var nameFieldLabel: String { + String( + localized: "charger.editor.field.name", + bundle: .main, + comment: "Label for the charger name text field" + ) + } + + private var namePlaceholder: String { + String( + localized: "charger.editor.placeholder.name", + bundle: .main, + comment: "Placeholder example for the charger name field" + ) + } + + private var electricalSectionTitle: String { + String( + localized: "charger.editor.section.electrical", + bundle: .main, + comment: "Section title for charger electrical configuration" + ) + } + + private var powerSectionTitle: String { + String( + localized: "charger.editor.section.power", + bundle: .main, + comment: "Section title for charger output power configuration" + ) + } + + private var appearanceEditorTitle: String { + NSLocalizedString( + "charger.editor.appearance.title", + bundle: .main, + value: "Charger Appearance", + comment: "Title for the charger appearance editor" + ) + } + + private var appearanceEditorSubtitle: String { + NSLocalizedString( + "charger.editor.appearance.subtitle", + bundle: .main, + value: "Customize how this charger shows up", + comment: "Subtitle shown in the charger appearance editor preview" + ) + } + + private var appearanceAccessibilityLabel: String { + NSLocalizedString( + "charger.editor.appearance.accessibility", + bundle: .main, + value: "Edit charger appearance", + comment: "Accessibility label for the charger appearance editor button" + ) + } + + init(configuration: ChargerConfiguration, onSave: @escaping (ChargerConfiguration) -> Void) { + _configuration = State(initialValue: configuration) + self.onSave = onSave + } + + var body: some View { + VStack(spacing: 0) { + headerInfoBar + List { + infoSection + electricalSection + powerSection + } + .listStyle(.insetGrouped) + .scrollIndicators(.hidden) + .scrollContentBackground(.hidden) + .background(Color.clear) + } + .background(Color(.systemGroupedBackground).ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(editorTitle) + .font(.headline.weight(.semibold)) + } + } + .sheet(isPresented: $showingAppearanceEditor) { + ItemEditorView( + title: appearanceEditorTitle, + nameFieldLabel: nameFieldLabel, + previewSubtitle: appearanceEditorSubtitle, + icons: chargerIconOptions, + 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 } + ) + ) + } + .onDisappear { + onSave(configuration) + } + } + + private var headerInfoBar: some View { + HStack(alignment: .center, spacing: 16) { + LoadIconView( + remoteIconURLString: nil, + fallbackSystemName: configuration.iconName, + fallbackColor: Color.componentColor(named: configuration.colorName), + size: 56 + ) + + VStack(alignment: .leading, spacing: 6) { + Text(configuration.name.isEmpty ? namePlaceholder : configuration.name) + .font(.title3.weight(.semibold)) + .lineLimit(1) + .truncationMode(.tail) + + Text(chargerSummary) + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(Color(.systemBackground)) + } + + private var infoSection: some View { + Section { + TextField( + nameFieldLabel, + text: Binding( + get: { configuration.name }, + set: { configuration.name = $0 } + ), + prompt: Text(namePlaceholder) + ) + .textInputAutocapitalization(.words) + .disableAutocorrection(true) + + Button(action: { showingAppearanceEditor = true }) { + Label( + appearanceEditorTitle, + systemImage: "paintbrush.pointed" + ) + } + .accessibilityLabel(appearanceAccessibilityLabel) + } + } + + private var electricalSection: some View { + Section(header: Text(electricalSectionTitle)) { + valueRow( + title: String( + localized: "charger.editor.field.input_voltage", + bundle: .main, + comment: "Label for the charger input voltage field" + ), + value: $configuration.inputVoltage, + unit: "V", + range: 0...400, + step: 1 + ) + + valueRow( + title: String( + localized: "charger.editor.field.output_voltage", + bundle: .main, + comment: "Label for the charger output voltage field" + ), + value: $configuration.outputVoltage, + unit: "V", + range: 0...80, + step: 0.1 + ) + } + } + + private var powerSection: some View { + Section(header: Text(powerSectionTitle), footer: powerFooterText) { + valueRow( + title: String( + localized: "charger.editor.field.current", + bundle: .main, + comment: "Label for the charger current field" + ), + value: $configuration.maxCurrentAmps, + unit: "A", + range: 0...200, + step: 0.5 + ) + + valueRow( + title: String( + localized: "charger.editor.field.power", + bundle: .main, + comment: "Label for the charger power field" + ), + value: $configuration.maxPowerWatts, + unit: "W", + range: 0...10000, + step: 50, + allowsZero: true + ) + } + } + + private func valueRow( + title: String, + value: Binding, + unit: String, + range: ClosedRange, + step: Double, + allowsZero: Bool = false + ) -> some View { + LabeledContent { + HStack(spacing: 8) { + Stepper( + value: Binding( + get: { + clamp(value.wrappedValue, to: range, allowsZero: allowsZero) + }, + set: { newValue in + let clamped = clamp(newValue, to: range, allowsZero: allowsZero) + if abs(clamped - value.wrappedValue) > .ulpOfOne { + value.wrappedValue = clamped + } + } + ), + in: range, + step: step + ) { + EmptyView() + } + .labelsHidden() + TextField( + "", + value: value, + format: .number.precision(.fractionLength(1)) + ) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(minWidth: 64) + Text(unit) + .foregroundStyle(.secondary) + } + } label: { + Text(title) + } + } + + private func clamp(_ value: Double, to range: ClosedRange, allowsZero: Bool) -> Double { + if allowsZero && value == 0 { + return 0 + } + return min(max(value, range.lowerBound), range.upperBound) + } + + private var chargerSummary: String { + let input = formattedValue(configuration.inputVoltage, unit: "V") + let output = formattedValue(configuration.outputVoltage, unit: "V") + let current = formattedValue(configuration.maxCurrentAmps, unit: "A") + return [input, output, current].joined(separator: " • ") + } + + private var powerFooterText: Text { + Text( + String( + localized: "charger.editor.field.power.footer", + bundle: .main, + comment: "Footer text explaining power input behaviour" + ) + ) + } + + private func formattedValue(_ value: Double, unit: String) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = unit == "A" ? 1 : 1 + formatter.minimumFractionDigits = 0 + guard let formatted = formatter.string(from: value as NSNumber) else { + return "\(value) \(unit)" + } + return "\(formatted)\(unit)" + } +} + +#Preview { + NavigationView { + ChargerEditorView( + configuration: ChargerConfiguration( + name: "Shore Power Charger", + inputVoltage: 230, + outputVoltage: 14.4, + maxCurrentAmps: 40, + maxPowerWatts: 600, + iconName: "bolt.fill", + colorName: "orange", + system: ElectricalSystem(name: "Preview System") + ), + onSave: { _ in } + ) + } +} diff --git a/Cable/Chargers/ChargersView.swift b/Cable/Chargers/ChargersView.swift index f5e876b..8fe808e 100644 --- a/Cable/Chargers/ChargersView.swift +++ b/Cable/Chargers/ChargersView.swift @@ -1,45 +1,379 @@ 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 - private var titleText: String { - let format = NSLocalizedString( - "chargers.title", - bundle: .main, - comment: "Title describing chargers belonging to a system" - ) - return String(format: format, system.name) + private struct SummaryMetric: Identifiable { + let id: String + let icon: String + let label: String + let value: String + let tint: Color } - private var subtitleText: String { + private var summaryTitle: String { String( - localized: "chargers.subtitle", + localized: "chargers.summary.title", bundle: .main, - comment: "Subtitle shown while chargers tab is under construction" + 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 = .constant(.inactive), + onAdd: @escaping () -> Void = {}, + onEdit: @escaping (SavedCharger) -> Void = { _ in }, + onDelete: @escaping (IndexSet) -> Void = { _ in } + ) { + self.system = system + self.chargers = chargers + self.onAdd = onAdd + self.onEdit = onEdit + self.onDelete = onDelete + _editMode = editMode + } + var body: some View { - VStack(spacing: 16) { - Image(systemName: "bolt.fill") - .font(.largeTitle) - .foregroundStyle(.secondary) + VStack(spacing: 0) { + if chargers.isEmpty { + emptyState + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + summarySection - Text(titleText) - .font(.title3) - .fontWeight(.semibold) - - Text(subtitleText) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + 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") + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) } + + private var summarySection: 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(.trailing, 16) + } + .scrollClipDisabled(true) + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + + Divider() + .background(Color(.separator)) + .padding(.leading, 16) + } + .background(Color(.systemGroupedBackground)) + } + + 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 + ) + .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: ElectricalSystem(name: "Preview System")) + ChargersView( + system: ChargersViewPreviewData.system, + chargers: ChargersViewPreviewData.chargers, + editMode: .constant(.inactive) + ) } diff --git a/Cable/Chargers/SavedCharger.swift b/Cable/Chargers/SavedCharger.swift new file mode 100644 index 0000000..05aaadf --- /dev/null +++ b/Cable/Chargers/SavedCharger.swift @@ -0,0 +1,59 @@ +import Foundation +import SwiftData + +@Model +final class SavedCharger { + @Attribute(.unique) var id: UUID + var name: String + var inputVoltage: Double + var outputVoltage: Double + var maxCurrentAmps: Double + var maxPowerWatts: Double + var iconName: String + var colorName: String + var system: ElectricalSystem? + var timestamp: Date + var remoteIconURLString: String? + var affiliateURLString: String? + var affiliateCountryCode: String? + var identifier: String + + init( + id: UUID = UUID(), + name: String, + inputVoltage: Double = 230.0, + outputVoltage: Double = 14.2, + maxCurrentAmps: Double = 30.0, + maxPowerWatts: Double = 0.0, + iconName: String = "bolt.fill", + colorName: String = "orange", + system: ElectricalSystem? = nil, + timestamp: Date = Date(), + remoteIconURLString: String? = nil, + affiliateURLString: String? = nil, + affiliateCountryCode: String? = nil, + identifier: String = UUID().uuidString + ) { + self.id = id + self.name = name + self.inputVoltage = inputVoltage + self.outputVoltage = outputVoltage + self.maxCurrentAmps = maxCurrentAmps + self.maxPowerWatts = maxPowerWatts + self.iconName = iconName + self.colorName = colorName + self.system = system + self.timestamp = timestamp + self.remoteIconURLString = remoteIconURLString + self.affiliateURLString = affiliateURLString + self.affiliateCountryCode = affiliateCountryCode + self.identifier = identifier + } + + var effectivePowerWatts: Double { + if maxPowerWatts > 0 { + return maxPowerWatts + } + return outputVoltage * maxCurrentAmps + } +} diff --git a/Cable/Loads/LoadIconView.swift b/Cable/Loads/LoadIconView.swift index 49249bb..15df9e1 100644 --- a/Cable/Loads/LoadIconView.swift +++ b/Cable/Loads/LoadIconView.swift @@ -75,3 +75,77 @@ struct LoadIconView: View { } } } + +struct ComponentSummaryMetricView: View { + let icon: String + let label: String + let value: String + let tint: Color + + var body: 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)) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + Text(label.uppercased()) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } +} + +struct ComponentMetricBadgeView: View { + let label: String + let value: String + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label.uppercased()) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .lineLimit(1) + Text(value) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(tint.opacity(0.12)) + ) + } +} + +extension Color { + static func componentColor(named 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 + } + } +} diff --git a/Cable/Loads/LoadsView.swift b/Cable/Loads/LoadsView.swift index c005bfa..7ff4c26 100644 --- a/Cable/Loads/LoadsView.swift +++ b/Cable/Loads/LoadsView.swift @@ -14,6 +14,7 @@ struct LoadsView: View { @EnvironmentObject var unitSettings: UnitSystemSettings @Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad] @Query(sort: \SavedBattery.timestamp, order: .reverse) private var allBatteries: [SavedBattery] + @Query(sort: \SavedCharger.timestamp, order: .reverse) private var allChargers: [SavedCharger] @State private var newLoadToEdit: SavedLoad? @State private var showingSystemEditor = false @State private var hasPresentedSystemEditorOnAppear = false @@ -22,6 +23,7 @@ struct LoadsView: View { @State private var showingSystemBOM = false @State private var selectedComponentTab: ComponentTab = .overview @State private var batteryDraft: BatteryConfiguration? + @State private var chargerDraft: ChargerConfiguration? @State private var activeStatus: LoadConfigurationStatus? @State private var editMode: EditMode = .inactive @@ -43,6 +45,10 @@ struct LoadsView: View { allBatteries.filter { $0.system == system } } + private var savedChargers: [SavedCharger] { + allChargers.filter { $0.system == system } + } + var body: some View { VStack(spacing: 0) { TabView(selection: $selectedComponentTab) { @@ -58,6 +64,7 @@ struct LoadsView: View { systemImage: "rectangle.3.group" ) } + componentsTab .tag(ComponentTab.components) .tabItem { @@ -70,47 +77,57 @@ struct LoadsView: View { systemImage: "square.stack.3d.up" ) } - Group { - if savedBatteries.isEmpty { - OnboardingInfoView( - configuration: .battery(), - onPrimaryAction: { startBatteryConfiguration() } - ) - } else { - BatteriesView( - system: system, - batteries: savedBatteries, - editMode: $editMode, - onEdit: { editBattery($0) }, - onDelete: deleteBatteries - ) - .environment(\.editMode, $editMode) - } - } - .tag(ComponentTab.batteries) - .tabItem { - Label( - String( - localized: "tab.batteries", - bundle: .main, - comment: "Tab title for battery configurations" - ), - systemImage: "battery.100" - ) - } - .environment(\.editMode, $editMode) - ChargersView(system: system) - .tag(ComponentTab.chargers) - .tabItem { - Label( - String( - localized: "tab.chargers", - bundle: .main, - comment: "Tab title for chargers view" - ), - systemImage: "bolt.fill" + + Group { + if savedBatteries.isEmpty { + OnboardingInfoView( + configuration: .battery(), + onPrimaryAction: { startBatteryConfiguration() } ) + } else { + BatteriesView( + system: system, + batteries: savedBatteries, + editMode: $editMode, + onEdit: { editBattery($0) }, + onDelete: deleteBatteries + ) + .environment(\.editMode, $editMode) } + } + .tag(ComponentTab.batteries) + .tabItem { + Label( + String( + localized: "tab.batteries", + bundle: .main, + comment: "Tab title for battery configurations" + ), + systemImage: "battery.100" + ) + } + .environment(\.editMode, $editMode) + + ChargersView( + system: system, + chargers: savedChargers, + editMode: $editMode, + onAdd: { startChargerConfiguration() }, + onEdit: { editCharger($0) }, + onDelete: deleteChargers + ) + .tag(ComponentTab.chargers) + .tabItem { + Label( + String( + localized: "tab.chargers", + bundle: .main, + comment: "Tab title for chargers view" + ), + systemImage: "bolt.fill" + ) + } + .environment(\.editMode, $editMode) } } .navigationBarTitleDisplayMode(.inline) @@ -122,7 +139,7 @@ struct LoadsView: View { HStack(spacing: 8) { ZStack { RoundedRectangle(cornerRadius: 6) - .fill(colorForName(system.colorName)) + .fill(Color.componentColor(named: system.colorName)) .frame(width: 24, height: 24) Image(systemName: system.iconName) @@ -142,8 +159,9 @@ struct LoadsView: View { let showPrimary = selectedComponentTab != .overview let showEditLoads = selectedComponentTab == .components && !savedLoads.isEmpty let showEditBatteries = selectedComponentTab == .batteries && !savedBatteries.isEmpty + let showEditChargers = selectedComponentTab == .chargers && !savedChargers.isEmpty - if showPrimary || showEditLoads || showEditBatteries { + if showPrimary || showEditLoads || showEditBatteries || showEditChargers { HStack { if showPrimary { Button(action: { @@ -151,7 +169,6 @@ struct LoadsView: View { }) { Image(systemName: "plus") } - .disabled(selectedComponentTab == .chargers) } if showEditLoads { EditButton() @@ -159,6 +176,9 @@ struct LoadsView: View { } else if showEditBatteries { EditButton() .disabled(savedBatteries.isEmpty) + } else if showEditChargers { + EditButton() + .disabled(savedChargers.isEmpty) } } } @@ -176,6 +196,15 @@ struct LoadsView: View { } ) } + .navigationDestination(item: $chargerDraft) { draft in + ChargerEditorView( + configuration: draft, + onSave: { configuration in + saveCharger(configuration) + chargerDraft = nil + } + ) + } .sheet(isPresented: $showingComponentLibrary) { ComponentLibraryView { item in addComponent(item) @@ -263,35 +292,12 @@ struct LoadsView: View { } private var summarySection: some View { - VStack(spacing: 0) { + ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { Text(loadsSummaryTitle) .font(.headline.weight(.semibold)) Spacer() - Button { - showingComponentLibrary = true - } label: { - HStack(spacing: 6) { - Image(systemName: "books.vertical") - Text( - String( - localized: "loads.library.button", - bundle: .main, - comment: "Button title to open component library" - ) - ) - } - .font(.footnote.weight(.semibold)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - Capsule(style: .continuous) - .fill(Color(.tertiarySystemFill)) - ) - } - .buttonStyle(.plain) - .foregroundStyle(Color.accentColor) } ViewThatFits(in: .horizontal) { @@ -353,9 +359,35 @@ struct LoadsView: View { Divider() .background(Color(.separator)) + + libraryButton + .padding(.trailing, 16) + .padding(.bottom, 6) } } + private var libraryButton: some View { + Button { + showingComponentLibrary = true + } label: { + Label( + String( + localized: "loads.library.button", + bundle: .main, + comment: "Button title to open component library" + ), + systemImage: "books.vertical" + ) + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule(style: .continuous)) + } + .buttonStyle(.plain) + .tint(.accentColor) + .shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 8) + } + private var componentsTab: some View { VStack(spacing: 0) { if savedLoads.isEmpty { @@ -402,7 +434,7 @@ struct LoadsView: View { LoadIconView( remoteIconURLString: load.remoteIconURLString, fallbackSystemName: load.iconName, - fallbackColor: colorForName(load.colorName), + fallbackColor: Color.componentColor(named: load.colorName), size: 48 ) @@ -611,19 +643,12 @@ struct LoadsView: View { } 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) - } + ComponentSummaryMetricView( + icon: icon, + label: label, + value: value, + tint: tint + ) } private func statusBanner(for status: LoadConfigurationStatus) -> some View { @@ -663,20 +688,10 @@ struct LoadsView: View { }() 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)) + ComponentMetricBadgeView( + label: label, + value: value, + tint: tint ) } @@ -697,7 +712,7 @@ struct LoadsView: View { case .batteries: startBatteryConfiguration() case .chargers: - break + startChargerConfiguration() } } @@ -706,7 +721,8 @@ struct LoadsView: View { for: system, in: modelContext, existingLoads: savedLoads, - existingBatteries: savedBatteries + existingBatteries: savedBatteries, + existingChargers: savedChargers ) newLoadToEdit = newLoad } @@ -715,7 +731,8 @@ struct LoadsView: View { batteryDraft = SystemComponentsPersistence.makeBatteryDraft( for: system, existingLoads: savedLoads, - existingBatteries: savedBatteries + existingBatteries: savedBatteries, + existingChargers: savedChargers ) } @@ -742,36 +759,50 @@ struct LoadsView: View { } } + private func startChargerConfiguration() { + chargerDraft = SystemComponentsPersistence.makeChargerDraft( + for: system, + existingLoads: savedLoads, + existingBatteries: savedBatteries, + existingChargers: savedChargers + ) + } + + private func saveCharger(_ configuration: ChargerConfiguration) { + SystemComponentsPersistence.saveCharger( + configuration, + for: system, + existingChargers: savedChargers, + in: modelContext + ) + } + + private func editCharger(_ charger: SavedCharger) { + chargerDraft = ChargerConfiguration(savedCharger: charger, system: system) + } + + private func deleteChargers(_ offsets: IndexSet) { + withAnimation { + SystemComponentsPersistence.deleteChargers( + at: offsets, + from: savedChargers, + in: modelContext + ) + } + } + private func addComponent(_ item: ComponentLibraryItem) { let newLoad = SystemComponentsPersistence.createLoad( from: item, for: system, in: modelContext, existingLoads: savedLoads, - existingBatteries: savedBatteries + existingBatteries: savedBatteries, + existingChargers: savedChargers ) newLoadToEdit = newLoad } - 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 awgFromCrossSection(_ crossSectionMM2: Double) -> Double { let awgSizes = [(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31), (10, 5.26), (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6), diff --git a/Cable/Loads/OnboardingInfoView.swift b/Cable/Loads/OnboardingInfoView.swift index 7033305..3acc0ec 100644 --- a/Cable/Loads/OnboardingInfoView.swift +++ b/Cable/Loads/OnboardingInfoView.swift @@ -140,9 +140,21 @@ extension OnboardingInfoView.Configuration { secondaryActionTitle: nil, secondaryActionIcon: nil, imageNames: [ - "charger-onboarding", - "router-onboarding", - "coffee-onboarding" + "battery-onboarding" + ] + ) + } + + static func charger() -> Self { + Self( + title: LocalizedStringKey("chargers.onboarding.title"), + subtitle: LocalizedStringKey("chargers.onboarding.subtitle"), + primaryActionTitle: LocalizedStringKey("chargers.onboarding.primary"), + primaryActionIcon: "plus", + secondaryActionTitle: nil, + secondaryActionIcon: nil, + imageNames: [ + "charger-onboarding" ] ) } diff --git a/Cable/Overview/SystemOverviewView.swift b/Cable/Overview/SystemOverviewView.swift index 8d3a04a..83b7162 100644 --- a/Cable/Overview/SystemOverviewView.swift +++ b/Cable/Overview/SystemOverviewView.swift @@ -34,7 +34,7 @@ struct SystemOverviewView: View { HStack(spacing: 14) { ZStack { RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(colorForName(system.colorName)) + .fill(Color.componentColor(named: system.colorName)) .frame(width: 54, height: 54) Image(systemName: system.iconName) .font(.system(size: 24, weight: .semibold)) @@ -513,25 +513,6 @@ struct SystemOverviewView: View { !batteries.isEmpty && !loads.isEmpty && formattedRuntime == 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 var loadsSummaryTitle: String { NSLocalizedString( "loads.overview.header.title", diff --git a/Cable/Systems/SystemComponentsPersistence.swift b/Cable/Systems/SystemComponentsPersistence.swift index 4919e2c..d664b24 100644 --- a/Cable/Systems/SystemComponentsPersistence.swift +++ b/Cable/Systems/SystemComponentsPersistence.swift @@ -7,7 +7,8 @@ struct SystemComponentsPersistence { for system: ElectricalSystem, in context: ModelContext, existingLoads: [SavedLoad], - existingBatteries: [SavedBattery] + existingBatteries: [SavedBattery], + existingChargers: [SavedCharger] ) -> SavedLoad { let defaultName = String( localized: "default.load.new", @@ -16,7 +17,8 @@ struct SystemComponentsPersistence { let loadName = uniqueName( startingWith: defaultName, loads: existingLoads, - batteries: existingBatteries + batteries: existingBatteries, + chargers: existingChargers ) let newLoad = SavedLoad( name: loadName, @@ -40,14 +42,16 @@ struct SystemComponentsPersistence { for system: ElectricalSystem, in context: ModelContext, existingLoads: [SavedLoad], - existingBatteries: [SavedBattery] + existingBatteries: [SavedBattery], + existingChargers: [SavedCharger] ) -> SavedLoad { let localizedName = item.localizedName let baseName = localizedName.isEmpty ? "Library Load" : localizedName let loadName = uniqueName( startingWith: baseName, loads: existingLoads, - batteries: existingBatteries + batteries: existingBatteries, + chargers: existingChargers ) let voltage = item.displayVoltage ?? 12.0 let power = item.watt ?? (item.current != nil ? item.current! * voltage : 0) @@ -85,7 +89,8 @@ struct SystemComponentsPersistence { static func makeBatteryDraft( for system: ElectricalSystem, existingLoads: [SavedLoad], - existingBatteries: [SavedBattery] + existingBatteries: [SavedBattery], + existingChargers: [SavedCharger] ) -> BatteryConfiguration { let defaultName = NSLocalizedString( "battery.editor.default_name", @@ -96,7 +101,8 @@ struct SystemComponentsPersistence { let batteryName = uniqueName( startingWith: defaultName, loads: existingLoads, - batteries: existingBatteries + batteries: existingBatteries, + chargers: existingChargers ) return BatteryConfiguration( name: batteryName, @@ -106,6 +112,32 @@ struct SystemComponentsPersistence { ) } + static func makeChargerDraft( + for system: ElectricalSystem, + existingLoads: [SavedLoad], + existingBatteries: [SavedBattery], + existingChargers: [SavedCharger] + ) -> ChargerConfiguration { + let defaultName = NSLocalizedString( + "charger.editor.default_name", + bundle: .main, + value: "New Charger", + comment: "Default name when configuring a new charger" + ) + let chargerName = uniqueName( + startingWith: defaultName, + loads: existingLoads, + batteries: existingBatteries, + chargers: existingChargers + ) + return ChargerConfiguration( + name: chargerName, + iconName: "bolt.fill", + colorName: system.colorName, + system: system + ) + } + static func saveBattery( _ configuration: BatteryConfiguration, for system: ElectricalSystem, @@ -129,6 +161,30 @@ struct SystemComponentsPersistence { } } + static func saveCharger( + _ configuration: ChargerConfiguration, + for system: ElectricalSystem, + existingChargers: [SavedCharger], + in context: ModelContext + ) { + if let existing = existingChargers.first(where: { $0.id == configuration.id }) { + configuration.apply(to: existing) + } else { + let newCharger = SavedCharger( + id: configuration.id, + name: configuration.name, + inputVoltage: configuration.inputVoltage, + outputVoltage: configuration.outputVoltage, + maxCurrentAmps: configuration.maxCurrentAmps, + maxPowerWatts: configuration.maxPowerWatts, + iconName: configuration.iconName, + colorName: configuration.colorName, + system: system + ) + context.insert(newCharger) + } + } + static func deleteBatteries( at offsets: IndexSet, from batteries: [SavedBattery], @@ -139,12 +195,27 @@ struct SystemComponentsPersistence { } } + static func deleteChargers( + at offsets: IndexSet, + from chargers: [SavedCharger], + in context: ModelContext + ) { + for index in offsets { + context.delete(chargers[index]) + } + } + static func uniqueName( startingWith baseName: String, loads: [SavedLoad], - batteries: [SavedBattery] + batteries: [SavedBattery], + chargers: [SavedCharger] ) -> String { - let existingNames = Set(loads.map { $0.name } + batteries.map { $0.name }) + let existingNames = Set( + loads.map { $0.name } + + batteries.map { $0.name } + + chargers.map { $0.name } + ) if !existingNames.contains(baseName) { return baseName @@ -160,4 +231,35 @@ struct SystemComponentsPersistence { return candidate } + + static func createDefaultCharger( + for system: ElectricalSystem, + in context: ModelContext, + existingLoads: [SavedLoad], + existingBatteries: [SavedBattery], + existingChargers: [SavedCharger] + ) -> SavedCharger { + let defaultName = String( + localized: "charger.default.new", + bundle: .main, + comment: "Default name when creating a new charger from system view" + ) + let chargerName = uniqueName( + startingWith: defaultName, + loads: existingLoads, + batteries: existingBatteries, + chargers: existingChargers + ) + let charger = SavedCharger( + name: chargerName, + inputVoltage: 230, + outputVoltage: 14.4, + maxCurrentAmps: 30, + iconName: "bolt.fill", + colorName: system.colorName, + system: system + ) + context.insert(charger) + return charger + } } diff --git a/Cable/Systems/SystemsView.swift b/Cable/Systems/SystemsView.swift index 1dcc728..d58bbd7 100644 --- a/Cable/Systems/SystemsView.swift +++ b/Cable/Systems/SystemsView.swift @@ -80,7 +80,7 @@ struct SystemsView: View { HStack(spacing: 12) { ZStack { RoundedRectangle(cornerRadius: 10) - .fill(colorForName(system.colorName)) + .fill(Color.componentColor(named: system.colorName)) .frame(width: 44, height: 44) Image(systemName: system.iconName) @@ -395,24 +395,6 @@ struct SystemsView: View { return uniqueKeywords } - 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 - } - } } #Preview("Sample Systems") { diff --git a/Cable/UITestSampleData.swift b/Cable/UITestSampleData.swift index 27e9bf3..4b9a636 100644 --- a/Cable/UITestSampleData.swift +++ b/Cable/UITestSampleData.swift @@ -31,14 +31,20 @@ private extension UITestSampleData { static func clearExistingData(in context: ModelContext) throws { let systemDescriptor = FetchDescriptor() let loadDescriptor = FetchDescriptor() + let batteryDescriptor = FetchDescriptor() + let chargerDescriptor = FetchDescriptor() let itemDescriptor = FetchDescriptor() let systems = try context.fetch(systemDescriptor) let loads = try context.fetch(loadDescriptor) + let batteries = try context.fetch(batteryDescriptor) + let chargers = try context.fetch(chargerDescriptor) let items = try context.fetch(itemDescriptor) systems.forEach { context.delete($0) } loads.forEach { context.delete($0) } + batteries.forEach { context.delete($0) } + chargers.forEach { context.delete($0) } items.forEach { context.delete($0) } } @@ -123,6 +129,47 @@ private extension UITestSampleData { workshopCharger.timestamp = Date(timeIntervalSinceReferenceDate: 2200) [vanFridge, vanLighting, workshopCompressor, workshopCharger].forEach { context.insert($0) } + + let shoreCharger = SavedCharger( + name: String(localized: "sample.charger.shore.name", comment: "Sample data name for a shore power charger"), + inputVoltage: 230.0, + outputVoltage: 14.4, + maxCurrentAmps: 40.0, + maxPowerWatts: 600.0, + iconName: "powerplug", + colorName: "orange", + system: adventureVan, + identifier: "sample.charger.shore" + ) + shoreCharger.timestamp = Date(timeIntervalSinceReferenceDate: 1300) + + let alternatorCharger = SavedCharger( + name: String(localized: "sample.charger.dcdc.name", comment: "Sample data name for a DC-DC charger"), + inputVoltage: 12.8, + outputVoltage: 14.2, + maxCurrentAmps: 30.0, + maxPowerWatts: 0.0, + iconName: "bolt.badge.clock", + colorName: "blue", + system: adventureVan, + identifier: "sample.charger.dcdc" + ) + alternatorCharger.timestamp = Date(timeIntervalSinceReferenceDate: 1350) + + let benchCharger = SavedCharger( + name: String(localized: "sample.charger.workbench.name", comment: "Sample data name for a workbench charger"), + inputVoltage: 120.0, + outputVoltage: 14.6, + maxCurrentAmps: 25.0, + maxPowerWatts: 365.0, + iconName: "bolt", + colorName: "green", + system: workshopBench, + identifier: "sample.charger.workbench" + ) + benchCharger.timestamp = Date(timeIntervalSinceReferenceDate: 2250) + + [shoreCharger, alternatorCharger, benchCharger].forEach { context.insert($0) } } } #endif diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index 1f435c6..aec7bf8 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -209,5 +209,38 @@ "battery.editor.alert.save" = "Speichern"; "battery.editor.default_name" = "Neue Batterie"; +"charger.editor.title" = "Ladegerät"; +"charger.editor.field.name" = "Name"; +"charger.editor.placeholder.name" = "Werkstattladegerät"; +"charger.editor.section.electrical" = "Elektrik"; +"charger.editor.section.power" = "Ladeausgang"; +"charger.editor.appearance.title" = "Ladegerät-Darstellung"; +"charger.editor.appearance.subtitle" = "Passe an, wie dieses Ladegerät angezeigt wird"; +"charger.editor.appearance.accessibility" = "Darstellung des Ladegeräts bearbeiten"; +"charger.editor.field.input_voltage" = "Eingangsspannung"; +"charger.editor.field.output_voltage" = "Ausgangsspannung"; +"charger.editor.field.current" = "Ladestrom"; +"charger.editor.field.power" = "Ladeleistung"; +"charger.editor.field.power.footer" = "Leer lassen, wenn keine Leistungsangabe vorliegt. Wir berechnen sie aus Spannung und Strom."; +"charger.editor.default_name" = "Neues Ladegerät"; +"charger.default.new" = "Neues Ladegerät"; + +"chargers.summary.title" = "Ladeübersicht"; +"chargers.summary.metric.count" = "Ladegeräte"; +"chargers.summary.metric.output" = "Spannung"; +"chargers.summary.metric.current" = "Ladestrom"; +"chargers.summary.metric.power" = "Ladeleistung"; +"chargers.badge.input" = "Eingang"; +"chargers.badge.output" = "Ausgang"; +"chargers.badge.current" = "Strom"; +"chargers.badge.power" = "Leistung"; +"chargers.onboarding.title" = "Füge deine Ladegeräte hinzu"; +"chargers.onboarding.subtitle" = "Verwalte Landstrom, Booster und Solarregler, um deine Ladeleistung im Blick zu behalten."; +"chargers.onboarding.primary" = "Ladegerät erstellen"; + +"sample.charger.shore.name" = "Landstrom-Ladegerät"; +"sample.charger.dcdc.name" = "DC-DC-Ladegerät"; +"sample.charger.workbench.name" = "Werkbank-Ladegerät"; + "chargers.title" = "Ladegeräte für %@"; "chargers.subtitle" = "Ladegeräte-Komponenten sind bald verfügbar."; diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings index 8078285..00af4da 100644 --- a/Cable/es.lproj/Localizable.strings +++ b/Cable/es.lproj/Localizable.strings @@ -208,5 +208,38 @@ "battery.editor.alert.save" = "Guardar"; "battery.editor.default_name" = "Nueva batería"; +"charger.editor.title" = "Cargador"; +"charger.editor.field.name" = "Nombre"; +"charger.editor.placeholder.name" = "Cargador de taller"; +"charger.editor.section.electrical" = "Eléctrico"; +"charger.editor.section.power" = "Salida de carga"; +"charger.editor.appearance.title" = "Apariencia del cargador"; +"charger.editor.appearance.subtitle" = "Personaliza cómo se muestra este cargador"; +"charger.editor.appearance.accessibility" = "Editar apariencia del cargador"; +"charger.editor.field.input_voltage" = "Voltaje de entrada"; +"charger.editor.field.output_voltage" = "Voltaje de salida"; +"charger.editor.field.current" = "Corriente de carga"; +"charger.editor.field.power" = "Potencia de carga"; +"charger.editor.field.power.footer" = "Déjalo en blanco si no se publica la potencia nominal. La calcularemos a partir del voltaje y la corriente."; +"charger.editor.default_name" = "Nuevo cargador"; +"charger.default.new" = "Nuevo cargador"; + +"chargers.summary.title" = "Resumen de carga"; +"chargers.summary.metric.count" = "Cargadores"; +"chargers.summary.metric.output" = "Voltaje de salida"; +"chargers.summary.metric.current" = "Tasa de carga"; +"chargers.summary.metric.power" = "Potencia de carga"; +"chargers.badge.input" = "Entrada"; +"chargers.badge.output" = "Salida"; +"chargers.badge.current" = "Corriente"; +"chargers.badge.power" = "Potencia"; +"chargers.onboarding.title" = "Añade tus cargadores"; +"chargers.onboarding.subtitle" = "Lleva el control del cargador de costa, los boosters y los controladores solares para saber cuánta potencia de carga tienes."; +"chargers.onboarding.primary" = "Crear cargador"; + +"sample.charger.shore.name" = "Cargador de costa"; +"sample.charger.dcdc.name" = "Cargador DC-DC"; +"sample.charger.workbench.name" = "Cargador de banco de trabajo"; + "chargers.title" = "Cargadores para %@"; "chargers.subtitle" = "Los componentes de carga estarán disponibles pronto."; diff --git a/Cable/fr.lproj/Localizable.strings b/Cable/fr.lproj/Localizable.strings index 2a8aba7..23a5e31 100644 --- a/Cable/fr.lproj/Localizable.strings +++ b/Cable/fr.lproj/Localizable.strings @@ -208,5 +208,38 @@ "battery.editor.alert.save" = "Enregistrer"; "battery.editor.default_name" = "Nouvelle batterie"; +"charger.editor.title" = "Chargeur"; +"charger.editor.field.name" = "Nom"; +"charger.editor.placeholder.name" = "Chargeur d'atelier"; +"charger.editor.section.electrical" = "Électrique"; +"charger.editor.section.power" = "Sortie de charge"; +"charger.editor.appearance.title" = "Apparence du chargeur"; +"charger.editor.appearance.subtitle" = "Personnalisez l'affichage de ce chargeur"; +"charger.editor.appearance.accessibility" = "Modifier l'apparence du chargeur"; +"charger.editor.field.input_voltage" = "Tension d'entrée"; +"charger.editor.field.output_voltage" = "Tension de sortie"; +"charger.editor.field.current" = "Courant de charge"; +"charger.editor.field.power" = "Puissance de charge"; +"charger.editor.field.power.footer" = "Laissez vide si la puissance nominale n'est pas indiquée. Nous la calculerons à partir de la tension et du courant."; +"charger.editor.default_name" = "Nouveau chargeur"; +"charger.default.new" = "Nouveau chargeur"; + +"chargers.summary.title" = "Aperçu de charge"; +"chargers.summary.metric.count" = "Chargeurs"; +"chargers.summary.metric.output" = "Tension de sortie"; +"chargers.summary.metric.current" = "Courant de charge"; +"chargers.summary.metric.power" = "Puissance de charge"; +"chargers.badge.input" = "Entrée"; +"chargers.badge.output" = "Sortie"; +"chargers.badge.current" = "Courant"; +"chargers.badge.power" = "Puissance"; +"chargers.onboarding.title" = "Ajoutez vos chargeurs"; +"chargers.onboarding.subtitle" = "Suivez l'alimentation quai, les boosters et les régulateurs solaires pour connaître votre capacité de charge."; +"chargers.onboarding.primary" = "Créer un chargeur"; + +"sample.charger.shore.name" = "Chargeur de quai"; +"sample.charger.dcdc.name" = "Chargeur DC-DC"; +"sample.charger.workbench.name" = "Chargeur d'établi"; + "chargers.title" = "Chargeurs pour %@"; "chargers.subtitle" = "Les chargeurs seront bientôt disponibles ici."; diff --git a/Cable/nl.lproj/Localizable.strings b/Cable/nl.lproj/Localizable.strings index 59a547e..07b8cd5 100644 --- a/Cable/nl.lproj/Localizable.strings +++ b/Cable/nl.lproj/Localizable.strings @@ -208,5 +208,38 @@ "battery.editor.alert.save" = "Opslaan"; "battery.editor.default_name" = "Nieuwe batterij"; +"charger.editor.title" = "Lader"; +"charger.editor.field.name" = "Naam"; +"charger.editor.placeholder.name" = "Werkplaatslader"; +"charger.editor.section.electrical" = "Elektrisch"; +"charger.editor.section.power" = "Laaduitgang"; +"charger.editor.appearance.title" = "Uiterlijk van lader"; +"charger.editor.appearance.subtitle" = "Bepaal hoe deze lader wordt weergegeven"; +"charger.editor.appearance.accessibility" = "Uiterlijk van lader bewerken"; +"charger.editor.field.input_voltage" = "Ingangsspanning"; +"charger.editor.field.output_voltage" = "Uitgangsspanning"; +"charger.editor.field.current" = "Laadstroom"; +"charger.editor.field.power" = "Laadvermogen"; +"charger.editor.field.power.footer" = "Laat leeg als het opgegeven vermogen ontbreekt. We berekenen het uit spanning en stroom."; +"charger.editor.default_name" = "Nieuwe lader"; +"charger.default.new" = "Nieuwe lader"; + +"chargers.summary.title" = "Laadoverzicht"; +"chargers.summary.metric.count" = "Laders"; +"chargers.summary.metric.output" = "Uitgangsspanning"; +"chargers.summary.metric.current" = "Laadstroom"; +"chargers.summary.metric.power" = "Laadvermogen"; +"chargers.badge.input" = "Ingang"; +"chargers.badge.output" = "Uitgang"; +"chargers.badge.current" = "Stroom"; +"chargers.badge.power" = "Vermogen"; +"chargers.onboarding.title" = "Voeg je laders toe"; +"chargers.onboarding.subtitle" = "Houd walstroom, boosters en zonne-regelaars bij om je laadcapaciteit te kennen."; +"chargers.onboarding.primary" = "Lader aanmaken"; + +"sample.charger.shore.name" = "Walstroomlader"; +"sample.charger.dcdc.name" = "DC-DC-lader"; +"sample.charger.workbench.name" = "Werkplaatslader"; + "chargers.title" = "Laders voor %@"; "chargers.subtitle" = "Ladercomponenten zijn binnenkort beschikbaar.";