Fix AWG notation, add alternator type, migrate to String(localized:)

- Fix AWG 0/00/000/0000 bug (all resolved to 0 in Swift) using negative
  int convention (-1 through -4) with formatAWG() for 1/0–4/0 display
- Add 7.5A fuse size and change fuse type from Int to Double
- Add alternator power source type with distinct bolt.car.fill icon
- Migrate all NSLocalizedString calls to String(localized:defaultValue:)
- Update translations for runtime subtitle (ES/FR/NL: current→maximum),
  usable capacity footer text, and NL override wording
- Store length always in meters, convert at display time in CalculatorView
- Add preview-friendly inits for ComponentLibraryView and LoadsView
- Expand test coverage for calculations, fuses, AWG, and edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 10:37:53 +01:00
parent 03878b9507
commit ea3b60d75c
24 changed files with 899 additions and 928 deletions

110
CLAUDE.md
View File

@@ -19,7 +19,7 @@ No external dependencies beyond the Xcode toolchain.
### Data Model Hierarchy ### Data Model Hierarchy
`ElectricalSystem` (top-level container) owns collections of: `ElectricalSystem` is the top-level container. Child entities reference their parent via an optional `system: ElectricalSystem?` property:
- `SavedLoad` — individual electrical loads with wire sizing parameters - `SavedLoad` — individual electrical loads with wire sizing parameters
- `SavedBattery` — battery banks with chemistry-specific capacity rules - `SavedBattery` — battery banks with chemistry-specific capacity rules
- `SavedCharger` — charging equipment specs - `SavedCharger` — charging equipment specs
@@ -29,8 +29,8 @@ All are `@Model` classes persisted via SwiftData. The container is configured in
### Key Layers ### Key Layers
- **Calculation engine** (`Loads/ElectricalCalculations.swift`): Pure static functions for wire cross-section sizing, voltage drop, power loss, and fuse recommendations. Uses copper resistivity (0.017 Ω·mm²/m) with a 5% max voltage drop constraint. Supports both metric (mm²) and imperial (AWG) wire standards. - **Calculation engine** (`Loads/ElectricalCalculations.swift`): Pure static functions for wire cross-section sizing, voltage drop, power loss, and fuse recommendations. Uses copper resistivity (0.017 Ω·mm²/m) with a 5% max voltage drop constraint. Supports both metric (mm²) and imperial (AWG) wire standards.
- **CableCalculator** (`Loads/CableCalculator.swift`): ObservableObject wrapper that bridges the calculation engine to SwiftUI views. - **CableCalculator** (`Loads/CableCalculator.swift`): ObservableObject wrapper that bridges the calculation engine to SwiftUI views. Instantiated per-view as `@StateObject`, not globally injected.
- **App-wide state**: `UnitSystemSettings` (metric/imperial, persisted to UserDefaults) and `StoreKitManager` (subscription status) are injected as `@EnvironmentObject`. - **App-wide state**: `UnitSystemSettings` (metric/imperial, persisted to UserDefaults) is injected as `@EnvironmentObject`.
### Navigation Flow ### Navigation Flow
@@ -38,7 +38,59 @@ All are `@Model` classes persisted via SwiftData. The container is configured in
### Feature Organization ### Feature Organization
Each feature has its own directory under `Cable/`: `Loads/`, `Systems/`, `Batteries/`, `Chargers/`, `Overview/`, `Paywall/`. Models and views for each feature live together. Each feature has its own directory under `Cable/`: `Loads/`, `Systems/`, `Batteries/`, `Chargers/`, `Overview/`, `Shared/`. Models and views for each feature live together.
### SwiftData Query Pattern
`@Query` fetches all records globally, then a computed property filters by system relationship — SwiftData doesn't efficiently handle relationship-based predicates in `@Query`:
```swift
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
private var savedLoads: [SavedLoad] { allLoads.filter { $0.system == system } }
```
### Unit Storage Convention
All values are stored in **metric** (meters, mm²). Conversion to imperial (feet, AWG) happens **at display time only**, never at storage time. `UnitSystemSettings.unitSystem` drives the display format.
## UI Patterns
### Editor Pattern (Battery, Charger, Load)
All editors follow the same architecture:
1. **Configuration struct** (e.g. `BatteryConfiguration`, `ChargerConfiguration`) as a transient draft — not an `@Model`. Has dual inits: one for new items, one from an existing `Saved*` model.
2. **Alert-based field editing**: Each numeric field gets its own `.alert()` with a `TextField`, bound via an `EditingField` enum. `onChange` updates the configuration live as the user types.
3. **Snap-to-values**: Common electrical values (12V, 24V, 48V, 230V, etc.) snap within a tolerance when the field is not actively being edited.
4. **Save flow**: `onSave: (Configuration) -> Void` callback to the parent view. `onDisappear` triggers save automatically. Parent calls `SystemComponentsPersistence` to persist.
5. **Appearance modal**: All editors include a `.sheet` presenting `ItemEditorView` for name/icon/color editing.
Navigation to editors uses `.navigationDestination(item: $draft)` where `$draft` is a `@State` optional Configuration.
### Shared UI Components
- **`ItemEditorView`** (`ItemEditorView.swift`): Shared appearance editor for name, icon (5-column SF Symbol grid), and color (6-column grid). Uses `AutoSelectTextField` for auto-focus. Accepts optional `additionalFields` builder.
- **`StatsHeaderContainer`** (`StatsHeaderContainer.swift`): Header card with scrollable metric pills. Uses `glassEffect` on iOS 26+, fallback with colored background + border.
- **`OnboardingInfoView`** (`Loads/OnboardingInfoView.swift`): Empty state with auto-rotating image carousel (8s interval). Configured via static factory methods: `.loads()`, `.battery()`, `.charger()`.
- **`LoadIconView`** (`Loads/LoadIconView.swift`): Component icon with remote URL support and `IconCache` (memory + file-based). Also exports `ComponentSummaryMetricView` and `ComponentMetricBadgeView`.
### List Styling Convention
All list views use consistent styling:
- `.listStyle(.plain)`, `.scrollContentBackground(.hidden)`, `.scrollIndicators(.hidden)`
- `.listRowSeparator(.hidden)`, `.listRowBackground(Color.clear)`
- Row cards: `RoundedRectangle(cornerRadius: 18, style: .continuous).fill(Color(.systemBackground))` with 16pt padding
- Headers via `.safeAreaInset(edge: .top)` containing `StatsHeaderContainer`
### Color Convention
`Color.componentColor(named:)` (in `LoadIconView.swift`) maps string names to SwiftUI colors (13 options: blue, green, orange, red, purple, yellow, pink, teal, indigo, mint, cyan, brown, gray). Used for all component icons. Badge backgrounds use `tint.opacity(0.12)`.
## Analytics
`AnalyticsTracker.log(_:properties:)` wraps Aptabase. Tracks: create, edit, update, delete, tab changes, navigation. In DEBUG builds, prints to NSLog.
## Component Library
`ComponentLibraryViewModel` fetches from a **PocketBase** backend with paginated API calls. Items have multi-language translations and locale-aware affiliate links (exact region match → language-only fallback → global fallback). Use `ComponentLibraryViewModel(previewItems:)` to bypass network in previews.
## Code Style ## Code Style
@@ -61,6 +113,52 @@ PDF exports use `UIGraphicsPDFRenderer` with A4 portrait format. The pattern is:
- **ShareSheet** triggered via `@State` item binding in the parent view. - **ShareSheet** triggered via `@State` item binding in the parent view.
- **Toolbar button** (not inline content) for the export action. - **Toolbar button** (not inline content) for the export action.
## StoreKit ## Screenshots & Previews
Subscription product IDs: `app.voltplan.cable.weekly`, `app.voltplan.cable.yearly`. Pro features are gated via `StoreKitManager.isPro`. Screenshot previews for all major views live in `Cable/ScreenshotPreviews.swift`. This file contains wrapper views and realistic sample data so screenshots include full app chrome (NavigationBar, TabBar).
### How to render screenshots
1. Use `mcp__xcode__RenderPreview` with `sourceFilePath: "Cable/ScreenshotPreviews.swift"` and `previewDefinitionIndexInFile` (044, grouped by view × 5 languages).
2. Output goes to `Shots/Screenshots/`.
### Preview index mapping
Previews are grouped in blocks of 5 (EN, DE, ES, FR, NL):
- 04: Overview tab
- 59: Components tab
- 1014: Batteries tab
- 1519: Chargers tab
- 2024: Systems list
- 2529: Parts Library
- 3034: Load editor (CalculatorView)
- 3539: Battery editor
- 4044: Charger editor
### Key patterns for preview-friendly views
- **LoadsView** accepts an `initialTab` parameter (`LoadsView.ComponentTab`) to control which tab is shown. Wrap in `NavigationStack` to get the navigation bar.
- **ComponentLibraryView** accepts an optional `viewModel` parameter via `init(viewModel:onSelect:)`. Use `ComponentLibraryViewModel(previewItems:)` to inject mock data and avoid network dependency.
- All preview wrappers inject `.modelContainer(container)` (in-memory) and `.environmentObject(UnitSystemSettings())`.
### Localization limitation
`.environment(\.locale, Locale(identifier: "xx"))` does **not** affect `String(localized:defaultValue:)` — those resolve from the app bundle, not the SwiftUI environment. All preview screenshots render in the system language. For multi-language screenshots, use UI tests with `-AppleLanguages` launch arguments or change the Xcode scheme language.
## Model Definitions
SwiftData `@Model` classes live in their feature directories: `ElectricalSystem` and `SavedLoad` in `Loads/CableCalculator.swift`, `SavedBattery` in `Batteries/SavedBattery.swift`, `SavedCharger` in `Chargers/SavedCharger.swift`. `Item.swift` is a legacy model.
## Xcode Project Structure
The project uses **automatic file inclusion** — no explicit file references in `project.pbxproj` for Swift sources. New `.swift` files in the `Cable/` directory are picked up automatically.
## Dependencies
- **SPM**: `aptabase-swift` (analytics) — referenced in `project.pbxproj`, resolved automatically by Xcode.
- **CocoaPods**: `Podfile` exists but has no pods listed — effectively unused.
- `Package.resolved` is in `xcshareddata` which is gitignored — it will be regenerated on a fresh checkout.
## SourceKit Diagnostics
SourceKit may show false-positive errors (e.g. "Cannot find type in scope") while the project builds successfully. These are indexing artifacts — always verify with an actual build via `mcp__xcode__BuildProject`.

View File

@@ -377,6 +377,7 @@
"charger.source.solar" = "Solar"; "charger.source.solar" = "Solar";
"charger.source.wind" = "Wind"; "charger.source.wind" = "Wind";
"charger.source.generator" = "Generator"; "charger.source.generator" = "Generator";
"charger.source.alternator" = "Alternator";
// MARK: - Share Menu // MARK: - Share Menu
"overview.share.diagram" = "Wiring Diagram"; "overview.share.diagram" = "Wiring Diagram";

View File

@@ -166,11 +166,9 @@ struct BatteriesView: View {
message: Text(detail.message), message: Text(detail.message),
dismissButton: .default( dismissButton: .default(
Text( Text(
NSLocalizedString( String(
"battery.bank.status.dismiss", localized: "battery.bank.status.dismiss",
bundle: .main, defaultValue: "Got it"
value: "Got it",
comment: "Dismiss button title for battery bank status alert"
) )
) )
) )
@@ -516,12 +514,9 @@ struct BatteriesView: View {
} }
private func emptySubtitle(for systemName: String) -> String { private func emptySubtitle(for systemName: String) -> String {
let format = NSLocalizedString( let format = String(
"battery.bank.empty.subtitle", localized: "battery.bank.empty.subtitle",
tableName: nil, defaultValue: "Tap the plus button to configure a battery for %@."
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) return String(format: format, systemName)
} }
@@ -530,75 +525,59 @@ struct BatteriesView: View {
switch status { switch status {
case let .voltage(target, mismatchedCount): case let .voltage(target, mismatchedCount):
let countText = mismatchedCount == 1 let countText = mismatchedCount == 1
? NSLocalizedString( ? String(
"battery.bank.status.single.battery", localized: "battery.bank.status.single.battery",
bundle: .main, defaultValue: "One battery"
value: "One battery",
comment: "Singular form describing mismatched battery count"
) )
: String( : String(
format: NSLocalizedString( format: String(
"battery.bank.status.multiple.batteries", localized: "battery.bank.status.multiple.batteries",
bundle: .main, defaultValue: "%d batteries"
value: "%d batteries",
comment: "Plural form describing mismatched battery count"
), ),
mismatchedCount mismatchedCount
) )
let expected = formattedValue(target, unit: "V") let expected = formattedValue(target, unit: "V")
let message = String( let message = String(
format: NSLocalizedString( format: String(
"battery.bank.status.voltage.message", localized: "battery.bank.status.voltage.message",
bundle: .main, defaultValue: "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters."
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, countText,
expected expected
) )
return ( return (
NSLocalizedString( String(
"battery.bank.status.voltage.title", localized: "battery.bank.status.voltage.title",
bundle: .main, defaultValue: "Voltage mismatch"
value: "Voltage mismatch",
comment: "Title for voltage mismatch alert"
), ),
message message
) )
case let .capacity(target, mismatchedCount): case let .capacity(target, mismatchedCount):
let countText = mismatchedCount == 1 let countText = mismatchedCount == 1
? NSLocalizedString( ? String(
"battery.bank.status.single.battery", localized: "battery.bank.status.single.battery",
bundle: .main, defaultValue: "One battery"
value: "One battery",
comment: "Singular form describing mismatched battery count"
) )
: String( : String(
format: NSLocalizedString( format: String(
"battery.bank.status.multiple.batteries", localized: "battery.bank.status.multiple.batteries",
bundle: .main, defaultValue: "%d batteries"
value: "%d batteries",
comment: "Plural form describing mismatched battery count"
), ),
mismatchedCount mismatchedCount
) )
let expected = formattedValue(target, unit: "Ah") let expected = formattedValue(target, unit: "Ah")
let message = String( let message = String(
format: NSLocalizedString( format: String(
"battery.bank.status.capacity.message", localized: "battery.bank.status.capacity.message",
bundle: .main, defaultValue: "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear."
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, countText,
expected expected
) )
return ( return (
NSLocalizedString( String(
"battery.bank.status.capacity.title", localized: "battery.bank.status.capacity.title",
bundle: .main, defaultValue: "Capacity mismatch"
value: "Capacity mismatch",
comment: "Title for capacity mismatch alert"
), ),
message message
) )

View File

@@ -108,245 +108,191 @@ struct BatteryEditorView: View {
} }
private var alertCancelTitle: String { private var alertCancelTitle: String {
NSLocalizedString( String(
"battery.editor.alert.cancel", localized: "battery.editor.alert.cancel",
bundle: .main, defaultValue: "Cancel"
value: "Cancel",
comment: "Cancel button title for edit alerts"
) )
} }
private var alertSaveTitle: String { private var alertSaveTitle: String {
NSLocalizedString( String(
"battery.editor.alert.save", localized: "battery.editor.alert.save",
bundle: .main, defaultValue: "Save"
value: "Save",
comment: "Save button title for edit alerts"
) )
} }
private var voltageAlertTitle: String { private var voltageAlertTitle: String {
NSLocalizedString( String(
"battery.editor.alert.voltage.title", localized: "battery.editor.alert.voltage.title",
bundle: .main, defaultValue: "Edit Nominal Voltage"
value: "Edit Nominal Voltage",
comment: "Title for the voltage edit alert"
) )
} }
private var voltageAlertPlaceholder: String { private var voltageAlertPlaceholder: String {
NSLocalizedString( String(
"battery.editor.alert.voltage.placeholder", localized: "battery.editor.alert.voltage.placeholder",
bundle: .main, defaultValue: "Voltage"
value: "Voltage",
comment: "Placeholder for voltage text field"
) )
} }
private var voltageAlertMessage: String { private var voltageAlertMessage: String {
NSLocalizedString( String(
"battery.editor.alert.voltage.message", localized: "battery.editor.alert.voltage.message",
bundle: .main, defaultValue: "Enter voltage in volts (V)"
value: "Enter voltage in volts (V)",
comment: "Message for the voltage edit alert"
) )
} }
private var capacityAlertTitle: String { private var capacityAlertTitle: String {
NSLocalizedString( String(
"battery.editor.alert.capacity.title", localized: "battery.editor.alert.capacity.title",
bundle: .main, defaultValue: "Edit Capacity"
value: "Edit Capacity",
comment: "Title for the capacity edit alert"
) )
} }
private var capacityAlertPlaceholder: String { private var capacityAlertPlaceholder: String {
NSLocalizedString( String(
"battery.editor.alert.capacity.placeholder", localized: "battery.editor.alert.capacity.placeholder",
bundle: .main, defaultValue: "Capacity"
value: "Capacity",
comment: "Placeholder for capacity text field"
) )
} }
private var capacityAlertMessage: String { private var capacityAlertMessage: String {
NSLocalizedString( String(
"battery.editor.alert.capacity.message", localized: "battery.editor.alert.capacity.message",
bundle: .main, defaultValue: "Enter capacity in amp-hours (Ah)"
value: "Enter capacity in amp-hours (Ah)",
comment: "Message for the capacity edit alert"
) )
} }
private var usableCapacityAlertTitle: String { private var usableCapacityAlertTitle: String {
NSLocalizedString( String(
"battery.editor.alert.usable_capacity.title", localized: "battery.editor.alert.usable_capacity.title",
bundle: .main, defaultValue: "Edit Usable Capacity"
value: "Edit Usable Capacity",
comment: "Title for the usable capacity edit alert"
) )
} }
private var usableCapacityAlertPlaceholder: String { private var usableCapacityAlertPlaceholder: String {
NSLocalizedString( String(
"battery.editor.alert.usable_capacity.placeholder", localized: "battery.editor.alert.usable_capacity.placeholder",
bundle: .main, defaultValue: "Usable Capacity (%)"
value: "Usable Capacity (%)",
comment: "Placeholder for the usable capacity text field"
) )
} }
private var usableCapacityAlertMessage: String { private var usableCapacityAlertMessage: String {
NSLocalizedString( String(
"battery.editor.alert.usable_capacity.message", localized: "battery.editor.alert.usable_capacity.message",
bundle: .main, defaultValue: "Enter usable capacity percentage (%)"
value: "Enter usable capacity as a percentage (%)",
comment: "Message for the usable capacity edit alert"
) )
} }
private var chargeVoltageAlertTitle: String { private var chargeVoltageAlertTitle: String {
NSLocalizedString( String(
"battery.editor.alert.charge_voltage.title", localized: "battery.editor.alert.charge_voltage.title",
bundle: .main, defaultValue: "Edit Charge Voltage"
value: "Edit Charge Voltage",
comment: "Title for the charge voltage edit alert"
) )
} }
private var chargeVoltageAlertPlaceholder: String { private var chargeVoltageAlertPlaceholder: String {
NSLocalizedString( String(
"battery.editor.alert.charge_voltage.placeholder", localized: "battery.editor.alert.charge_voltage.placeholder",
bundle: .main, defaultValue: "Charge Voltage"
value: "Charge Voltage",
comment: "Placeholder for the charge voltage text field"
) )
} }
private var chargeVoltageAlertMessage: String { private var chargeVoltageAlertMessage: String {
NSLocalizedString( String(
"battery.editor.alert.charge_voltage.message", localized: "battery.editor.alert.charge_voltage.message",
bundle: .main, defaultValue: "Enter charge voltage in volts (V)"
value: "Enter charge voltage in volts (V)",
comment: "Message for the charge voltage edit alert"
) )
} }
private var cutOffVoltageAlertTitle: String { private var cutOffVoltageAlertTitle: String {
NSLocalizedString( String(
"battery.editor.alert.cutoff_voltage.title", localized: "battery.editor.alert.cutoff_voltage.title",
bundle: .main, defaultValue: "Edit Cut-off Voltage"
value: "Edit Cut-off Voltage",
comment: "Title for the cut-off voltage edit alert"
) )
} }
private var cutOffVoltageAlertPlaceholder: String { private var cutOffVoltageAlertPlaceholder: String {
NSLocalizedString( String(
"battery.editor.alert.cutoff_voltage.placeholder", localized: "battery.editor.alert.cutoff_voltage.placeholder",
bundle: .main, defaultValue: "Cut-off Voltage"
value: "Cut-off Voltage",
comment: "Placeholder for the cut-off voltage text field"
) )
} }
private var cutOffVoltageAlertMessage: String { private var cutOffVoltageAlertMessage: String {
NSLocalizedString( String(
"battery.editor.alert.cutoff_voltage.message", localized: "battery.editor.alert.cutoff_voltage.message",
bundle: .main, defaultValue: "Enter cut-off voltage in volts (V)"
value: "Enter cut-off voltage in volts (V)",
comment: "Message for the cut-off voltage edit alert"
) )
} }
private var chargeVoltageTitle: String { private var chargeVoltageTitle: String {
NSLocalizedString( String(
"battery.editor.slider.charge_voltage", localized: "battery.editor.slider.charge_voltage",
bundle: .main, defaultValue: "Charge Voltage"
value: "Charge Voltage",
comment: "Title for the charge voltage slider"
) )
} }
private var cutOffVoltageTitle: String { private var cutOffVoltageTitle: String {
NSLocalizedString( String(
"battery.editor.slider.cutoff_voltage", localized: "battery.editor.slider.cutoff_voltage",
bundle: .main, defaultValue: "Cut-off Voltage"
value: "Cut-off Voltage",
comment: "Title for the cut-off voltage slider"
) )
} }
private var temperatureRangeTitle: String { private var temperatureRangeTitle: String {
NSLocalizedString( String(
"battery.editor.slider.temperature_range", localized: "battery.editor.slider.temperature_range",
bundle: .main, defaultValue: "Temperature Range"
value: "Temperature Range",
comment: "Title for the temperature range editor"
) )
} }
private var chargeVoltageHelperText: String { private var chargeVoltageHelperText: String {
NSLocalizedString( String(
"battery.editor.advanced.charge_voltage.helper", localized: "battery.editor.advanced.charge_voltage.helper",
bundle: .main, defaultValue: "Set the maximum recommended charging voltage."
value: "Set the maximum recommended charging voltage.",
comment: "Helper text explaining charge voltage"
) )
} }
private var cutOffVoltageHelperText: String { private var cutOffVoltageHelperText: String {
NSLocalizedString( String(
"battery.editor.advanced.cutoff_voltage.helper", localized: "battery.editor.advanced.cutoff_voltage.helper",
bundle: .main, defaultValue: "Set the minimum safe discharge voltage."
value: "Set the minimum safe discharge voltage.",
comment: "Helper text explaining cut-off voltage"
) )
} }
private var temperatureRangeHelperText: String { private var temperatureRangeHelperText: String {
NSLocalizedString( String(
"battery.editor.advanced.temperature_range.helper", localized: "battery.editor.advanced.temperature_range.helper",
bundle: .main, defaultValue: "Define the recommended operating temperature range."
value: "Define the recommended operating temperature range.",
comment: "Helper text explaining temperature range"
) )
} }
private var minimumTemperatureLabel: String { private var minimumTemperatureLabel: String {
NSLocalizedString( String(
"battery.editor.slider.temperature_range.min", localized: "battery.editor.slider.temperature_range.min",
bundle: .main, defaultValue: "Minimum"
value: "Minimum",
comment: "Label for minimum temperature control"
) )
} }
private var maximumTemperatureLabel: String { private var maximumTemperatureLabel: String {
NSLocalizedString( String(
"battery.editor.slider.temperature_range.max", localized: "battery.editor.slider.temperature_range.max",
bundle: .main, defaultValue: "Maximum"
value: "Maximum",
comment: "Label for maximum temperature control"
) )
} }
private var usableCapacityDefaultFooterFormat: String { private var usableCapacityDefaultFooterFormat: String {
NSLocalizedString( String(
"battery.editor.advanced.usable_capacity.footer_default", localized: "battery.editor.advanced.usable_capacity.footer_default",
bundle: .main, defaultValue: "Defaults to %@ based on chemistry."
value: "Default value %@ based on chemistry.",
comment: "Footer text explaining the default usable capacity value"
) )
} }
private var usableCapacityOverrideFooterFormat: String { private var usableCapacityOverrideFooterFormat: String {
NSLocalizedString( String(
"battery.editor.advanced.usable_capacity.footer_override", localized: "battery.editor.advanced.usable_capacity.footer_override",
bundle: .main, defaultValue: "Override active. Chemistry default remains %@."
value: "Manual override active. Chemistry default remains %@.",
comment: "Footer text explaining the usable capacity override"
) )
} }
@@ -388,29 +334,23 @@ struct BatteryEditorView: View {
} }
private var appearanceEditorTitle: String { private var appearanceEditorTitle: String {
NSLocalizedString( String(
"battery.editor.appearance.title", localized: "battery.editor.appearance.title",
bundle: .main, defaultValue: "Battery Appearance"
value: "Battery Appearance",
comment: "Title for the battery appearance editor"
) )
} }
private var appearanceEditorSubtitle: String { private var appearanceEditorSubtitle: String {
NSLocalizedString( String(
"battery.editor.appearance.subtitle", localized: "battery.editor.appearance.subtitle",
bundle: .main, defaultValue: "Customize how this battery shows up"
value: "Customize how this battery shows up",
comment: "Subtitle shown in the battery appearance editor preview"
) )
} }
private var appearanceAccessibilityLabel: String { private var appearanceAccessibilityLabel: String {
NSLocalizedString( String(
"battery.editor.appearance.accessibility", localized: "battery.editor.appearance.accessibility",
bundle: .main, defaultValue: "Edit battery appearance"
value: "Edit battery appearance",
comment: "Accessibility label for the battery appearance editor button"
) )
} }
@@ -527,11 +467,9 @@ struct BatteryEditorView: View {
) )
} }
.alert( .alert(
NSLocalizedString( String(
"battery.editor.alert.minimum_temperature.title", localized: "battery.editor.alert.minimum_temperature.title",
bundle: .main, defaultValue: "Edit Minimum Temperature"
value: "Edit Minimum Temperature",
comment: "Title for the minimum temperature edit alert"
), ),
isPresented: Binding( isPresented: Binding(
get: { temperatureEditingField == .minimumTemperature }, get: { temperatureEditingField == .minimumTemperature },
@@ -544,11 +482,9 @@ struct BatteryEditorView: View {
) )
) { ) {
TextField( TextField(
NSLocalizedString( String(
"battery.editor.alert.minimum_temperature.placeholder", localized: "battery.editor.alert.minimum_temperature.placeholder",
bundle: .main, defaultValue: "Minimum Temperature (\u{00B0}C)"
value: "Minimum Temperature (°C)",
comment: "Placeholder for the minimum temperature text field"
), ),
text: $minimumTemperatureInput text: $minimumTemperatureInput
) )
@@ -564,11 +500,9 @@ struct BatteryEditorView: View {
} }
Button( Button(
NSLocalizedString( String(
"battery.editor.alert.cancel", localized: "battery.editor.alert.cancel",
bundle: .main, defaultValue: "Cancel"
value: "Cancel",
comment: "Cancel button title for edit alerts"
), ),
role: .cancel role: .cancel
) { ) {
@@ -577,11 +511,9 @@ struct BatteryEditorView: View {
} }
Button( Button(
NSLocalizedString( String(
"battery.editor.alert.save", localized: "battery.editor.alert.save",
bundle: .main, defaultValue: "Save"
value: "Save",
comment: "Save button title for edit alerts"
) )
) { ) {
if let parsed = parseInput(minimumTemperatureInput) { if let parsed = parseInput(minimumTemperatureInput) {
@@ -592,20 +524,16 @@ struct BatteryEditorView: View {
} }
} message: { } message: {
Text( Text(
NSLocalizedString( String(
"battery.editor.alert.minimum_temperature.message", localized: "battery.editor.alert.minimum_temperature.message",
bundle: .main, defaultValue: "Enter minimum temperature in degrees Celsius (\u{00B0}C)"
value: "Enter minimum temperature in degrees Celsius (°C)",
comment: "Message for the minimum temperature edit alert"
) )
) )
} }
.alert( .alert(
NSLocalizedString( String(
"battery.editor.alert.maximum_temperature.title", localized: "battery.editor.alert.maximum_temperature.title",
bundle: .main, defaultValue: "Edit Maximum Temperature"
value: "Edit Maximum Temperature",
comment: "Title for the maximum temperature edit alert"
), ),
isPresented: Binding( isPresented: Binding(
get: { temperatureEditingField == .maximumTemperature }, get: { temperatureEditingField == .maximumTemperature },
@@ -618,11 +546,9 @@ struct BatteryEditorView: View {
) )
) { ) {
TextField( TextField(
NSLocalizedString( String(
"battery.editor.alert.maximum_temperature.placeholder", localized: "battery.editor.alert.maximum_temperature.placeholder",
bundle: .main, defaultValue: "Maximum Temperature (\u{00B0}C)"
value: "Maximum Temperature (°C)",
comment: "Placeholder for the maximum temperature text field"
), ),
text: $maximumTemperatureInput text: $maximumTemperatureInput
) )
@@ -638,11 +564,9 @@ struct BatteryEditorView: View {
} }
Button( Button(
NSLocalizedString( String(
"battery.editor.alert.cancel", localized: "battery.editor.alert.cancel",
bundle: .main, defaultValue: "Cancel"
value: "Cancel",
comment: "Cancel button title for edit alerts"
), ),
role: .cancel role: .cancel
) { ) {
@@ -651,11 +575,9 @@ struct BatteryEditorView: View {
} }
Button( Button(
NSLocalizedString( String(
"battery.editor.alert.save", localized: "battery.editor.alert.save",
bundle: .main, defaultValue: "Save"
value: "Save",
comment: "Save button title for edit alerts"
) )
) { ) {
if let parsed = parseInput(maximumTemperatureInput) { if let parsed = parseInput(maximumTemperatureInput) {
@@ -666,11 +588,9 @@ struct BatteryEditorView: View {
} }
} message: { } message: {
Text( Text(
NSLocalizedString( String(
"battery.editor.alert.maximum_temperature.message", localized: "battery.editor.alert.maximum_temperature.message",
bundle: .main, defaultValue: "Enter maximum temperature in degrees Celsius (\u{00B0}C)"
value: "Enter maximum temperature in degrees Celsius (°C)",
comment: "Message for the maximum temperature edit alert"
) )
) )
} }

View File

@@ -120,29 +120,23 @@ struct ChargerEditorView: View {
} }
private var appearanceEditorTitle: String { private var appearanceEditorTitle: String {
NSLocalizedString( String(
"charger.editor.appearance.title", localized: "charger.editor.appearance.title",
bundle: .main, defaultValue: "Charger Appearance"
value: "Charger Appearance",
comment: "Title for the charger appearance editor"
) )
} }
private var appearanceEditorSubtitle: String { private var appearanceEditorSubtitle: String {
NSLocalizedString( String(
"charger.editor.appearance.subtitle", localized: "charger.editor.appearance.subtitle",
bundle: .main, defaultValue: "Customize how this charger shows up"
value: "Customize how this charger shows up",
comment: "Subtitle shown in the charger appearance editor preview"
) )
} }
private var appearanceAccessibilityLabel: String { private var appearanceAccessibilityLabel: String {
NSLocalizedString( String(
"charger.editor.appearance.accessibility", localized: "charger.editor.appearance.accessibility",
bundle: .main, defaultValue: "Edit charger appearance"
value: "Edit charger appearance",
comment: "Accessibility label for the charger appearance editor button"
) )
} }
@@ -195,92 +189,72 @@ struct ChargerEditorView: View {
} }
private var inputVoltageAlertTitle: String { private var inputVoltageAlertTitle: String {
NSLocalizedString( String(
"charger.editor.alert.input_voltage.title", localized: "charger.editor.alert.input_voltage.title",
bundle: .main, defaultValue: "Edit Input Voltage"
value: "Edit Input Voltage",
comment: "Title for the input voltage edit alert"
) )
} }
private var outputVoltageAlertTitle: String { private var outputVoltageAlertTitle: String {
NSLocalizedString( String(
"charger.editor.alert.output_voltage.title", localized: "charger.editor.alert.output_voltage.title",
bundle: .main, defaultValue: "Edit Output Voltage"
value: "Edit Output Voltage",
comment: "Title for the output voltage edit alert"
) )
} }
private var currentAlertTitle: String { private var currentAlertTitle: String {
NSLocalizedString( String(
"charger.editor.alert.current.title", localized: "charger.editor.alert.current.title",
bundle: .main, defaultValue: "Edit Charge Current"
value: "Edit Charge Current",
comment: "Title for the charging current edit alert"
) )
} }
private var powerAlertTitle: String { private var powerAlertTitle: String {
NSLocalizedString( String(
"charger.editor.alert.power.title", localized: "charger.editor.alert.power.title",
bundle: .main, defaultValue: "Edit Charge Power"
value: "Edit Charge Power",
comment: "Title for the power edit alert"
) )
} }
private var alertMessageVoltage: String { private var alertMessageVoltage: String {
NSLocalizedString( String(
"charger.editor.alert.voltage.message", localized: "charger.editor.alert.voltage.message",
bundle: .main, defaultValue: "Enter voltage in volts (V)"
value: "Enter voltage in volts (V)",
comment: "Message for voltage edit alerts"
) )
} }
private var alertMessagePower: String { private var alertMessagePower: String {
NSLocalizedString( String(
"charger.editor.alert.power.message", localized: "charger.editor.alert.power.message",
bundle: .main, defaultValue: "Enter power in watts (W)"
value: "Enter power in watts (W)",
comment: "Message for the power edit alert"
) )
} }
private var alertMessageCurrent: String { private var alertMessageCurrent: String {
NSLocalizedString( String(
"charger.editor.alert.current.message", localized: "charger.editor.alert.current.message",
bundle: .main, defaultValue: "Enter current in amps (A)"
value: "Enter current in amps (A)",
comment: "Message for the current edit alert"
) )
} }
private var alertCancelTitle: String { private var alertCancelTitle: String {
NSLocalizedString( String(
"charger.editor.alert.cancel", localized: "charger.editor.alert.cancel",
bundle: .main, defaultValue: "Cancel"
value: "Cancel",
comment: "Title for cancel buttons in edit alerts"
) )
} }
private var alertSaveTitle: String { private var alertSaveTitle: String {
NSLocalizedString( String(
"charger.editor.alert.save", localized: "charger.editor.alert.save",
bundle: .main, defaultValue: "Save"
value: "Save",
comment: "Title for save buttons in edit alerts"
) )
} }
private var powerAlertPlaceholder: String { private var powerAlertPlaceholder: String {
NSLocalizedString( String(
"charger.editor.alert.power.placeholder", localized: "charger.editor.alert.power.placeholder",
bundle: .main, defaultValue: "Power"
value: "Power",
comment: "Placeholder for the power edit alert"
) )
} }

View File

@@ -25,6 +25,7 @@ final class SavedCharger {
case solar = "solar" case solar = "solar"
case wind = "wind" case wind = "wind"
case generator = "generator" case generator = "generator"
case alternator = "alternator"
var id: Self { self } var id: Self { self }
@@ -34,6 +35,7 @@ final class SavedCharger {
case .solar: return String(localized: "charger.source.solar", defaultValue: "Solar") case .solar: return String(localized: "charger.source.solar", defaultValue: "Solar")
case .wind: return String(localized: "charger.source.wind", defaultValue: "Wind") case .wind: return String(localized: "charger.source.wind", defaultValue: "Wind")
case .generator: return String(localized: "charger.source.generator", defaultValue: "Generator") case .generator: return String(localized: "charger.source.generator", defaultValue: "Generator")
case .alternator: return String(localized: "charger.source.alternator", defaultValue: "Alternator")
} }
} }
@@ -43,6 +45,7 @@ final class SavedCharger {
case .solar: return "sun.max.fill" case .solar: return "sun.max.fill"
case .wind: return "wind" case .wind: return "wind"
case .generator: return "engine.combustion.fill" case .generator: return "engine.combustion.fill"
case .alternator: return "bolt.car.fill"
} }
} }
} }

View File

@@ -73,9 +73,14 @@ class CableCalculator: ObservableObject {
) )
} }
var recommendedFuse: Int { var recommendedFuse: Double {
ElectricalCalculations.recommendedFuse(forCurrent: current) ElectricalCalculations.recommendedFuse(forCurrent: current)
} }
var recommendedFuseFormatted: String {
let fuse = recommendedFuse
return fuse == fuse.rounded() ? String(format: "%.0f", fuse) : String(format: "%.1f", fuse)
}
} }
@Model @Model

View File

@@ -103,12 +103,12 @@ struct CalculatorView: View {
.keyboardType(.decimalPad) .keyboardType(.decimalPad)
.onAppear { .onAppear {
if lengthInput.isEmpty { if lengthInput.isEmpty {
lengthInput = formattedValue(calculator.length) lengthInput = formattedValue(displayLength)
} }
} }
.onChange(of: lengthInput) { _, newValue in .onChange(of: lengthInput) { _, newValue in
guard editingValue == .length, let parsed = parseInput(newValue) else { return } guard editingValue == .length, let parsed = parseInput(newValue) else { return }
calculator.length = roundToTenth(parsed) calculator.length = metersFromDisplayLength(roundToTenth(parsed))
} }
Button("Cancel", role: .cancel) { Button("Cancel", role: .cancel) {
editingValue = nil editingValue = nil
@@ -116,7 +116,7 @@ struct CalculatorView: View {
} }
Button("Save") { Button("Save") {
if let parsed = parseInput(lengthInput) { if let parsed = parseInput(lengthInput) {
calculator.length = roundToTenth(parsed) calculator.length = metersFromDisplayLength(roundToTenth(parsed))
} }
editingValue = nil editingValue = nil
lengthInput = "" lengthInput = ""
@@ -520,7 +520,7 @@ struct CalculatorView: View {
.font(.caption2) .font(.caption2)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("\(calculator.recommendedFuse)A") Text("\(calculator.recommendedFuseFormatted)A")
.font(.subheadline) .font(.subheadline)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.orange) .foregroundColor(.orange)
@@ -535,11 +535,9 @@ struct CalculatorView: View {
.font(.caption2) .font(.caption2)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text(String(format: unitSettings.unitSystem == .imperial ? Text(unitSettings.unitSystem == .imperial ?
"%.1fft @ %.0f AWG" : String(format: "%.1fft @ %@ AWG", displayLength, ElectricalCalculations.formatAWG(calculator.crossSection(for: unitSettings.unitSystem))) :
"%.1fm @ %.1fmm²", String(format: "%.1fm @ %.1fmm²", displayLength, calculator.crossSection(for: unitSettings.unitSystem)))
unitSettings.unitSystem == .imperial ? calculator.length * 3.28084 : calculator.length,
calculator.crossSection(for: unitSettings.unitSystem)))
.font(.subheadline) .font(.subheadline)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.blue) .foregroundColor(.blue)
@@ -600,7 +598,7 @@ struct CalculatorView: View {
.frame(height: 30) .frame(height: 30)
.frame(width:80) .frame(width:80)
.overlay( .overlay(
Text("\(calculator.recommendedFuse)A") Text("\(calculator.recommendedFuseFormatted)A")
.foregroundColor(.white) .foregroundColor(.white)
.fontWeight(.bold) .fontWeight(.bold)
) )
@@ -698,7 +696,7 @@ struct CalculatorView: View {
Group { Group {
Button(action: {}) { Button(action: {}) {
Text(unitSettings.unitSystem == .imperial ? Text(unitSettings.unitSystem == .imperial ?
String(format: "%.0f AWG", calculator.crossSection(for: unitSettings.unitSystem)) : "\(ElectricalCalculations.formatAWG(calculator.crossSection(for: unitSettings.unitSystem))) AWG" :
String(format: "%.1f mm²", calculator.crossSection(for: unitSettings.unitSystem))) String(format: "%.1f mm²", calculator.crossSection(for: unitSettings.unitSystem)))
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(.blue) .foregroundColor(.blue)
@@ -708,7 +706,7 @@ struct CalculatorView: View {
Text("").foregroundColor(.secondary) Text("").foregroundColor(.secondary)
Button(action: { beginLengthEditing() }) { Button(action: { beginLengthEditing() }) {
Text(String(format: "%.1f %@", calculator.length, unitSettings.unitSystem.lengthUnit)) Text(String(format: "%.1f %@", displayLength, unitSettings.unitSystem.lengthUnit))
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(.primary) .foregroundColor(.primary)
} }
@@ -775,13 +773,9 @@ struct CalculatorView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
let descriptionKey = info.affiliateURL != nil let description = info.affiliateURL != nil
? "affiliate.description.with_link" ? String(localized: "affiliate.description.with_link", defaultValue: "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan.")
: "affiliate.description.without_link" : String(localized: "affiliate.description.without_link", defaultValue: "Tapping above shows a full bill of materials with shopping searches to help you source parts.")
let description = NSLocalizedString(
descriptionKey,
comment: "Explanation text beneath the affiliate button"
)
Text(description) Text(description)
.font(.caption2) .font(.caption2)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -801,7 +795,7 @@ struct CalculatorView: View {
let unknownSizeLabel = String(localized: "bom.size.unknown", comment: "Fallback label when the cable size is unknown") let unknownSizeLabel = String(localized: "bom.size.unknown", comment: "Fallback label when the cable size is unknown")
if unitSystem == .imperial { if unitSystem == .imperial {
if crossSectionValue > 0 { if crossSectionValue > 0 {
crossSectionLabel = String(format: "AWG %.0f", crossSectionValue) crossSectionLabel = "AWG \(ElectricalCalculations.formatAWG(crossSectionValue))"
} else { } else {
crossSectionLabel = unknownSizeLabel crossSectionLabel = unknownSizeLabel
} }
@@ -816,35 +810,29 @@ struct CalculatorView: View {
let cableDetail = "\(lengthLabel)\(crossSectionLabel)" let cableDetail = "\(lengthLabel)\(crossSectionLabel)"
let powerDetail = String(format: "%.0f W @ %.1f V", calculator.calculatedPower, calculator.voltage) let powerDetail = String(format: "%.0f W @ %.1f V", calculator.calculatedPower, calculator.voltage)
let fuseRating = calculator.recommendedFuse let fuseRating = calculator.recommendedFuseFormatted
let fuseDetailFormat = NSLocalizedString( let fuseDetailFormat = String(localized: "bom.fuse.detail", defaultValue: "Inline holder and %@A fuse")
"bom.fuse.detail",
comment: "Description for the fuse entry in the calculator BOM"
)
let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating) let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating)
let cableShoesDetailFormat = NSLocalizedString( let cableShoesDetailFormat = String(localized: "bom.terminals.detail", defaultValue: "Ring or spade terminals sized for %@ wiring")
"bom.terminals.detail",
comment: "Description for the cable terminals entry in the calculator BOM"
)
let cableShoesDetail = String.localizedStringWithFormat(cableShoesDetailFormat, crossSectionLabel.lowercased()) let cableShoesDetail = String.localizedStringWithFormat(cableShoesDetailFormat, crossSectionLabel.lowercased())
let cableGaugeQuery: String let cableGaugeQuery: String
if unitSystem == .imperial { if unitSystem == .imperial {
cableGaugeQuery = String(format: "AWG %.0f", crossSectionValue) cableGaugeQuery = "AWG \(ElectricalCalculations.formatAWG(crossSectionValue))"
} else { } else {
cableGaugeQuery = String(format: "%.1f mm2", crossSectionValue) cableGaugeQuery = String(format: "%.1f mm2", crossSectionValue)
} }
let redCableSearchFormat = NSLocalizedString("bom.search.cable.red", comment: "Amazon search query for red battery cable") let redCableSearchFormat = String(localized: "bom.search.cable.red", defaultValue: "%@ red battery cable")
let redCableQuery = String(format: redCableSearchFormat, cableGaugeQuery) let redCableQuery = String(format: redCableSearchFormat, cableGaugeQuery)
let blackCableSearchFormat = NSLocalizedString("bom.search.cable.black", comment: "Amazon search query for black battery cable") let blackCableSearchFormat = String(localized: "bom.search.cable.black", defaultValue: "%@ black battery cable")
let blackCableQuery = String(format: blackCableSearchFormat, cableGaugeQuery) let blackCableQuery = String(format: blackCableSearchFormat, cableGaugeQuery)
let fuseSearchFormat = NSLocalizedString("bom.search.fuse", comment: "Amazon search query for inline fuse holder") let fuseSearchFormat = String(localized: "bom.search.fuse", defaultValue: "inline fuse holder %@A")
let fuseQuery = String(format: fuseSearchFormat, fuseRating) let fuseQuery = String(format: fuseSearchFormat, fuseRating)
let terminalSearchFormat = NSLocalizedString("bom.search.terminals", comment: "Amazon search query for cable shoes") let terminalSearchFormat = String(localized: "bom.search.terminals", defaultValue: "%@ cable shoes")
let terminalQuery = String(format: terminalSearchFormat, cableGaugeQuery) let terminalQuery = String(format: terminalSearchFormat, cableGaugeQuery)
let deviceFallbackFormat = NSLocalizedString("bom.search.device.fallback", comment: "Amazon search query fallback for a DC device") let deviceFallbackFormat = String(localized: "bom.search.device.fallback", defaultValue: "DC device %.0fW %.0fV")
let deviceQueryBase = calculator.loadName.isEmpty let deviceQueryBase = calculator.loadName.isEmpty
? String(format: deviceFallbackFormat, calculator.calculatedPower, calculator.voltage) ? String(format: deviceFallbackFormat, calculator.calculatedPower, calculator.voltage)
: calculator.loadName : calculator.loadName
@@ -1068,7 +1056,7 @@ struct CalculatorView: View {
private var lengthSliderRange: ClosedRange<Double> { private var lengthSliderRange: ClosedRange<Double> {
let baseMax = unitSettings.unitSystem == .metric ? 20.0 : 60.0 let baseMax = unitSettings.unitSystem == .metric ? 20.0 : 60.0
let upperBound = max(baseMax, calculator.length) let upperBound = max(baseMax, displayLength)
return 0...upperBound return 0...upperBound
} }
@@ -1187,18 +1175,15 @@ struct CalculatorView: View {
} }
private var lengthSlider: some View { private var lengthSlider: some View {
let lengthTitleFormat = NSLocalizedString( let lengthTitleFormat = String(localized: "slider.length.title", defaultValue: "Cable Length (%@)")
"slider.length.title",
comment: "Title format for the cable length slider"
)
return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit), return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit),
value: Binding( value: Binding(
get: { calculator.length }, get: { displayLength },
set: { newValue in set: { newValue in
if editingValue == .length { if editingValue == .length {
calculator.length = roundToTenth(newValue) calculator.length = metersFromDisplayLength(roundToTenth(newValue))
} else { } else {
calculator.length = normalizedLength(for: newValue) calculator.length = metersFromDisplayLength(normalizedLength(for: newValue))
} }
} }
), ),
@@ -1306,6 +1291,21 @@ struct CalculatorView: View {
max(0, (value * 10).rounded() / 10) max(0, (value * 10).rounded() / 10)
} }
// MARK: - Length Unit Conversion (calculator.length is always in meters)
private static let metersToFeet = 3.28084
private static let feetToMeters = 0.3048
/// Length in display units (meters or feet) for showing to the user
private var displayLength: Double {
unitSettings.unitSystem == .imperial ? calculator.length * Self.metersToFeet : calculator.length
}
/// Convert a display-unit length (meters or feet) back to meters for storage
private func metersFromDisplayLength(_ displayValue: Double) -> Double {
unitSettings.unitSystem == .imperial ? displayValue * Self.feetToMeters : displayValue
}
private func roundToNearestFive(_ value: Double) -> Double { private func roundToNearestFive(_ value: Double) -> Double {
max(0, (value / 5).rounded() * 5) max(0, (value / 5).rounded() * 5)
} }
@@ -1342,7 +1342,7 @@ struct CalculatorView: View {
} }
private func beginLengthEditing() { private func beginLengthEditing() {
lengthInput = formattedValue(calculator.length) lengthInput = formattedValue(displayLength)
editingValue = .length editingValue = .length
} }
@@ -1433,16 +1433,10 @@ private struct BillOfMaterialsView: View {
let destinationURL = destinationURL(for: item) let destinationURL = destinationURL(for: item)
let accessibilityLabel: String = { let accessibilityLabel: String = {
if isCompleted { if isCompleted {
let format = NSLocalizedString( let format = String(localized: "bom.accessibility.mark.incomplete", defaultValue: "Mark %@ incomplete")
"bom.accessibility.mark.incomplete",
comment: "Accessibility label to mark a BOM item incomplete"
)
return String.localizedStringWithFormat(format, item.title) return String.localizedStringWithFormat(format, item.title)
} else { } else {
let format = NSLocalizedString( let format = String(localized: "bom.accessibility.mark.complete", defaultValue: "Mark %@ complete")
"bom.accessibility.mark.complete",
comment: "Accessibility label to mark a BOM item complete"
)
return String.localizedStringWithFormat(format, item.title) return String.localizedStringWithFormat(format, item.title)
} }
}() }()

View File

@@ -211,6 +211,12 @@ final class ComponentLibraryViewModel: ObservableObject {
self.urlSession = urlSession self.urlSession = urlSession
} }
init(previewItems: [ComponentLibraryItem]) {
self.urlSession = .shared
self.items = previewItems
self.isLoading = false
}
func load() async { func load() async {
guard !isLoading else { return } guard !isLoading else { return }
isLoading = true isLoading = true
@@ -528,10 +534,20 @@ final class ComponentLibraryViewModel: ObservableObject {
struct ComponentLibraryView: View { struct ComponentLibraryView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = ComponentLibraryViewModel() @StateObject private var viewModel: ComponentLibraryViewModel
@State private var searchText: String = "" @State private var searchText: String = ""
let onSelect: (ComponentLibraryItem) -> Void let onSelect: (ComponentLibraryItem) -> Void
init(onSelect: @escaping (ComponentLibraryItem) -> Void) {
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel())
self.onSelect = onSelect
}
init(viewModel: ComponentLibraryViewModel, onSelect: @escaping (ComponentLibraryItem) -> Void) {
self._viewModel = StateObject(wrappedValue: viewModel)
self.onSelect = onSelect
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
content content

View File

@@ -17,24 +17,26 @@ struct ElectricalCalculations {
150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0, 150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0,
] ]
private static let standardAWG: [Int] = [20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, 0, 00, 000, 0000] // Convention: 1/0 = -1, 2/0 = -2, 3/0 = -3, 4/0 = -4
private static let standardAWG: [Int] = [20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, -1, -2, -3, -4]
private static let awgCrossSections: [Double] = [ private static let awgCrossSections: [Double] = [
0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0, 0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0,
] ]
private static let standardFuses: [Int] = [ private static let standardFuses: [Double] = [
1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 1, 2, 3, 5, 7.5, 10, 15, 20, 25, 30, 35, 40, 50,
60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250,
300, 350, 400, 450, 500, 600, 700, 800, 300, 350, 400, 450, 500, 600, 700, 800,
] ]
/// Length must always be in meters. unitSystem controls the output format (mm² vs AWG).
static func recommendedCrossSection( static func recommendedCrossSection(
length: Double, length: Double,
current: Double, current: Double,
voltage: Double, voltage: Double,
unitSystem: UnitSystem unitSystem: UnitSystem
) -> Double { ) -> Double {
let lengthInMeters = unitSystem == .metric ? length : length * feetToMeters let lengthInMeters = length
let maxVoltageDrop = voltage * maxVoltageDropFraction let maxVoltageDrop = voltage * maxVoltageDropFraction
let minimumCrossSection = guardAgainstZero(maxVoltageDrop) { let minimumCrossSection = guardAgainstZero(maxVoltageDrop) {
(2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop (2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop
@@ -65,7 +67,7 @@ struct ElectricalCalculations {
unitSystem: unitSystem unitSystem: unitSystem
) )
let lengthInMeters = unitSystem == .metric ? length : length * feetToMeters let lengthInMeters = length
let crossSectionMM2: Double let crossSectionMM2: Double
if unitSystem == .metric { if unitSystem == .metric {
crossSectionMM2 = selectedCrossSection crossSectionMM2 = selectedCrossSection
@@ -112,8 +114,8 @@ struct ElectricalCalculations {
return current * drop return current * drop
} }
static func recommendedFuse(forCurrent current: Double) -> Int { static func recommendedFuse(forCurrent current: Double) -> Double {
let target = Int((current * 1.25).rounded(.up)) let target = (current * 1.25).rounded(.up)
return standardFuses.first(where: { $0 >= target }) ?? standardFuses.last ?? target return standardFuses.first(where: { $0 >= target }) ?? standardFuses.last ?? target
} }
@@ -123,16 +125,22 @@ struct ElectricalCalculations {
} }
private static func crossSectionFromAWG(_ awg: Double) -> Double { private static func crossSectionFromAWG(_ awg: Double) -> Double {
switch awg {
case 00: return 67.4
case 000: return 85.0
case 0000: return 107.0
default:
let index = standardAWG.firstIndex(of: Int(awg)) ?? -1 let index = standardAWG.firstIndex(of: Int(awg)) ?? -1
if index >= 0 && index < awgCrossSections.count { if index >= 0 && index < awgCrossSections.count {
return awgCrossSections[index] return awgCrossSections[index]
} }
return 0.75 return 0.75
} }
/// Formats an AWG value for display: positive values as-is, negative as "X/0" notation.
static func formatAWG(_ awg: Double) -> String {
let intAWG = Int(awg)
switch intAWG {
case -1: return "1/0"
case -2: return "2/0"
case -3: return "3/0"
case -4: return "4/0"
default: return "\(intAWG)"
}
} }
} }

View File

@@ -27,11 +27,9 @@ enum LoadConfigurationStatus: Identifiable, Equatable {
var bannerText: String { var bannerText: String {
switch self { switch self {
case .missingDetails: case .missingDetails:
return NSLocalizedString( return String(
"loads.overview.status.missing_details.banner", localized: "loads.overview.status.missing_details.banner",
bundle: .main, defaultValue: "Finish configuring your loads"
value: "Finish configuring your loads",
comment: "Short banner text describing loads that need additional details"
) )
} }
} }
@@ -39,30 +37,22 @@ enum LoadConfigurationStatus: Identifiable, Equatable {
func detailInfo() -> LoadStatusDetail { func detailInfo() -> LoadStatusDetail {
switch self { switch self {
case .missingDetails(let count): case .missingDetails(let count):
let title = NSLocalizedString( let title = String(
"loads.overview.status.missing_details.title", localized: "loads.overview.status.missing_details.title",
bundle: .main, defaultValue: "Missing load details"
value: "Missing load details",
comment: "Alert title when loads are missing required details"
) )
let format = NSLocalizedString( let format = String(
"loads.overview.status.missing_details.message", localized: "loads.overview.status.missing_details.message",
bundle: .main, defaultValue: "Enter cable length and wire size for %d %@ to see accurate recommendations."
value: "Enter cable length and wire size for %d %@ to see accurate recommendations.",
comment: "Alert message when loads are missing required details"
) )
let loadWord = count == 1 let loadWord = count == 1
? NSLocalizedString( ? String(
"loads.overview.status.missing_details.singular", localized: "loads.overview.status.missing_details.singular",
bundle: .main, defaultValue: "load"
value: "load",
comment: "Singular noun for load"
) )
: NSLocalizedString( : String(
"loads.overview.status.missing_details.plural", localized: "loads.overview.status.missing_details.plural",
bundle: .main, defaultValue: "loads"
value: "loads",
comment: "Plural noun for loads"
) )
let message = String(format: format, count, loadWord) let message = String(format: format, count, loadWord)
return LoadStatusDetail(title: title, message: message) return LoadStatusDetail(title: title, message: message)

View File

@@ -21,7 +21,7 @@ struct LoadsView: View {
@State private var hasOpenedLoadOnAppear = false @State private var hasOpenedLoadOnAppear = false
@State private var showingComponentLibrary = false @State private var showingComponentLibrary = false
@State private var showingSystemBOM = false @State private var showingSystemBOM = false
@State private var selectedComponentTab: ComponentTab = .overview @State private var selectedComponentTab: ComponentTab
@State private var batteryDraft: BatteryConfiguration? @State private var batteryDraft: BatteryConfiguration?
@State private var chargerDraft: ChargerConfiguration? @State private var chargerDraft: ChargerConfiguration?
@State private var activeStatus: LoadConfigurationStatus? @State private var activeStatus: LoadConfigurationStatus?
@@ -36,10 +36,11 @@ struct LoadsView: View {
private let presentSystemEditorOnAppear: Bool private let presentSystemEditorOnAppear: Bool
private let loadToOpenOnAppear: SavedLoad? private let loadToOpenOnAppear: SavedLoad?
init(system: ElectricalSystem, presentSystemEditorOnAppear: Bool = false, loadToOpenOnAppear: SavedLoad? = nil) { init(system: ElectricalSystem, presentSystemEditorOnAppear: Bool = false, loadToOpenOnAppear: SavedLoad? = nil, initialTab: ComponentTab = .overview) {
self.system = system self.system = system
self.presentSystemEditorOnAppear = presentSystemEditorOnAppear self.presentSystemEditorOnAppear = presentSystemEditorOnAppear
self.loadToOpenOnAppear = loadToOpenOnAppear self.loadToOpenOnAppear = loadToOpenOnAppear
self._selectedComponentTab = State(initialValue: initialTab)
} }
private var savedLoads: [SavedLoad] { private var savedLoads: [SavedLoad] {
@@ -312,12 +313,7 @@ struct LoadsView: View {
message: Text(detail.message), message: Text(detail.message),
dismissButton: .default( dismissButton: .default(
Text( Text(
NSLocalizedString( String(localized: "battery.bank.status.dismiss", defaultValue: "Got it")
"battery.bank.status.dismiss",
bundle: .main,
value: "Got it",
comment: "Dismiss button title for load status alert"
)
) )
) )
) )
@@ -610,7 +606,7 @@ struct LoadsView: View {
private func wireGaugeString(for load: SavedLoad) -> String { private func wireGaugeString(for load: SavedLoad) -> String {
if unitSettings.unitSystem == .imperial { if unitSettings.unitSystem == .imperial {
let awgValue = awgFromCrossSection(load.crossSection) let awgValue = awgFromCrossSection(load.crossSection)
return String(format: "%.0f AWG", awgValue) return "\(ElectricalCalculations.formatAWG(awgValue)) AWG"
} else { } else {
return String(format: "%.1f mm²", load.crossSection) return String(format: "%.1f mm²", load.crossSection)
} }
@@ -679,66 +675,31 @@ struct LoadsView: View {
} }
private var fuseMetricLabel: String { private var fuseMetricLabel: String {
NSLocalizedString( String(localized: "loads.metric.fuse", defaultValue: "Fuse")
"loads.metric.fuse",
bundle: .main,
value: "Fuse",
comment: "Label for fuse metric in load detail row"
)
} }
private var cableMetricLabel: String { private var cableMetricLabel: String {
NSLocalizedString( String(localized: "loads.metric.cable", defaultValue: "Cable")
"loads.metric.cable",
bundle: .main,
value: "Cable",
comment: "Label for cable metric in load detail row"
)
} }
private var lengthMetricLabel: String { private var lengthMetricLabel: String {
NSLocalizedString( String(localized: "loads.metric.length", defaultValue: "Length")
"loads.metric.length",
bundle: .main,
value: "Length",
comment: "Label for cable length metric in load detail row"
)
} }
private var loadsSummaryTitle: String { private var loadsSummaryTitle: String {
NSLocalizedString( String(localized: "loads.overview.header.title", defaultValue: "Load Overview")
"loads.overview.header.title",
bundle: .main,
value: "Load Overview",
comment: "Title for the loads overview summary section"
)
} }
private var loadsCountLabel: String { private var loadsCountLabel: String {
NSLocalizedString( String(localized: "loads.overview.metric.count", defaultValue: "Loads")
"loads.overview.metric.count",
bundle: .main,
value: "Loads",
comment: "Label for number of loads metric"
)
} }
private var loadsCurrentLabel: String { private var loadsCurrentLabel: String {
NSLocalizedString( String(localized: "loads.overview.metric.current", defaultValue: "Total Current")
"loads.overview.metric.current",
bundle: .main,
value: "Total Current",
comment: "Label for total load current metric"
)
} }
private var loadsPowerLabel: String { private var loadsPowerLabel: String {
NSLocalizedString( String(localized: "loads.overview.metric.power", defaultValue: "Total Power")
"loads.overview.metric.power",
bundle: .main,
value: "Total Power",
comment: "Label for total load power metric"
)
} }
private var totalCurrent: Double { private var totalCurrent: Double {
@@ -1047,20 +1008,21 @@ struct LoadsView: View {
} }
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double { private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
let awgSizes = [(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31), let awgSizes: [(Int, Double)] = [(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), (10, 5.26), (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6),
(1, 42.4), (0, 53.5), (00, 67.4), (000, 85.0), (0000, 107.0)] (1, 42.4), (-1, 53.5), (-2, 67.4), (-3, 85.0), (-4, 107.0)]
// Find the closest AWG size // Find the closest AWG size
let closest = awgSizes.min { abs($0.1 - crossSectionMM2) < abs($1.1 - crossSectionMM2) } let closest = awgSizes.min { abs($0.1 - crossSectionMM2) < abs($1.1 - crossSectionMM2) }
return Double(closest?.0 ?? 20) return Double(closest?.0 ?? 20)
} }
private func recommendedFuse(for load: SavedLoad) -> Int { private func recommendedFuse(for load: SavedLoad) -> String {
ElectricalCalculations.recommendedFuse(forCurrent: load.current) let fuse = ElectricalCalculations.recommendedFuse(forCurrent: load.current)
return fuse == fuse.rounded() ? String(format: "%.0f", fuse) : String(format: "%.1f", fuse)
} }
private enum ComponentTab: Hashable { enum ComponentTab: Hashable {
case overview case overview
case components case components
case batteries case batteries
@@ -1146,19 +1108,6 @@ struct LoadsView: View {
UIRectFill(CGRect(origin: .zero, size: size)) UIRectFill(CGRect(origin: .zero, size: size))
image.draw(at: .zero) image.draw(at: .zero)
// Draw app icon as subtle watermark in bottom-right corner
if let appIcon = UIImage(named: "AppIconWatermark") {
let watermarkSize: CGFloat = min(size.width, size.height) * 0.08
let margin: CGFloat = watermarkSize * 0.4
let watermarkRect = CGRect(
x: size.width - watermarkSize - margin,
y: size.height - watermarkSize - margin,
width: watermarkSize,
height: watermarkSize
)
appIcon.draw(in: watermarkRect, blendMode: .normal, alpha: 0.15)
}
return UIGraphicsGetImageFromCurrentImageContext() return UIGraphicsGetImageFromCurrentImageContext()
} }
@@ -1268,3 +1217,4 @@ struct LoadsView: View {
} }
} }
} }

View File

@@ -47,7 +47,7 @@ struct SystemOverviewPDFExporter {
let voltageDrop: Double let voltageDrop: Double
let voltageDropPercent: Double let voltageDropPercent: Double
let powerLoss: Double let powerLoss: Double
let recommendedFuse: Int let recommendedFuse: Double
let iconUrl: String? let iconUrl: String?
} }
@@ -77,6 +77,7 @@ struct SystemOverviewPDFExporter {
static func fetchDiagramImage(snapshot: SystemSnapshot) async -> UIImage? { static func fetchDiagramImage(snapshot: SystemSnapshot) async -> UIImage? {
let payload: [String: Any] = [ let payload: [String: Any] = [
"systemName": snapshot.systemName, "systemName": snapshot.systemName,
"source": "cable",
"unitSystem": snapshot.unitSystem == .metric ? "metric" : "imperial", "unitSystem": snapshot.unitSystem == .metric ? "metric" : "imperial",
"loads": snapshot.loads.map { load in "loads": snapshot.loads.map { load in
var dict: [String: Any] = [ var dict: [String: Any] = [
@@ -777,7 +778,7 @@ struct SystemOverviewPDFExporter {
(voltageLabel, "\(formatNumber(load.voltage)) V", currentLabel, "\(formatNumber(load.current)) A"), (voltageLabel, "\(formatNumber(load.voltage)) V", currentLabel, "\(formatNumber(load.current)) A"),
(powerLabel, formatPower(load.power), lengthLabel, "\(formatNumber(load.length)) \(lengthUnit)"), (powerLabel, formatPower(load.power), lengthLabel, "\(formatNumber(load.length)) \(lengthUnit)"),
(cableLabel, "\(formatNumber(load.recommendedCrossSection)) \(wireUnit)", vDropLabel, "\(formatNumber(load.voltageDropPercent))%"), (cableLabel, "\(formatNumber(load.recommendedCrossSection)) \(wireUnit)", vDropLabel, "\(formatNumber(load.voltageDropPercent))%"),
(pLossLabel, formatPower(load.powerLoss), fuseLabel, "\(load.recommendedFuse) A"), (pLossLabel, formatPower(load.powerLoss), fuseLabel, "\(formatNumber(load.recommendedFuse)) A"),
] ]
for row in rows { for row in rows {

View File

@@ -398,11 +398,9 @@ struct SystemOverviewView: View {
message: Text(detail.message), message: Text(detail.message),
dismissButton: .default( dismissButton: .default(
Text( Text(
NSLocalizedString( String(
"battery.bank.status.dismiss", localized: "battery.bank.status.dismiss",
bundle: .main, defaultValue: "Got it"
value: "Got it",
comment: "Dismiss button title for load status alert"
) )
) )
) )
@@ -962,266 +960,123 @@ struct SystemOverviewView: View {
} }
private var loadsSummaryTitle: String { private var loadsSummaryTitle: String {
NSLocalizedString( String(localized: "loads.overview.header.title", defaultValue: "Load Overview")
"loads.overview.header.title",
bundle: .main,
value: "Load Overview",
comment: "Title for the loads overview summary section"
)
} }
private var loadsCountLabel: String { private var loadsCountLabel: String {
NSLocalizedString( String(localized: "loads.overview.metric.count", defaultValue: "Loads")
"loads.overview.metric.count",
bundle: .main,
value: "Loads",
comment: "Label for number of loads metric"
)
} }
private var loadsCurrentLabel: String { private var loadsCurrentLabel: String {
NSLocalizedString( String(localized: "loads.overview.metric.current", defaultValue: "Total Current")
"loads.overview.metric.current",
bundle: .main,
value: "Total Current",
comment: "Label for total load current metric"
)
} }
private var loadsPowerLabel: String { private var loadsPowerLabel: String {
NSLocalizedString( String(localized: "loads.overview.metric.power", defaultValue: "Total Power")
"loads.overview.metric.power",
bundle: .main,
value: "Total Power",
comment: "Label for total load power metric"
)
} }
private var loadsEmptyTitle: String { private var loadsEmptyTitle: String {
NSLocalizedString( String(localized: "overview.loads.empty.title", defaultValue: "No loads configured yet")
"overview.loads.empty.title",
bundle: .main,
value: "No loads configured yet",
comment: "Title shown in overview when no loads exist"
)
} }
private var loadsEmptySubtitle: String { private var loadsEmptySubtitle: String {
NSLocalizedString( String(localized: "overview.loads.empty.subtitle", defaultValue: "Add components to get cable sizing and fuse recommendations tailored to this system.")
"overview.loads.empty.subtitle",
bundle: .main,
value: "Add components to get cable sizing and fuse recommendations tailored to this system.",
comment: "Subtitle shown in overview when no loads exist"
)
} }
private var loadsEmptyMessage: String { private var loadsEmptyMessage: String {
NSLocalizedString( String(localized: "loads.overview.empty.message", defaultValue: "Start by adding a load to see system insights.")
"loads.overview.empty.message",
bundle: .main,
value: "Start by adding a load to see system insights.",
comment: "Message shown when no loads exist"
)
} }
private var loadsEmptyCreateAction: String { private var loadsEmptyCreateAction: String {
NSLocalizedString( String(localized: "loads.overview.empty.create", defaultValue: "Add Load")
"loads.overview.empty.create",
bundle: .main,
value: "Create Load",
comment: "Button title to create a new load"
)
} }
private var loadsEmptyBrowseAction: String { private var loadsEmptyBrowseAction: String {
NSLocalizedString( String(localized: "loads.overview.empty.library", defaultValue: "Browse Library")
"loads.overview.empty.library",
bundle: .main,
value: "Browse Library",
comment: "Button title to open load library"
)
} }
private var batterySummaryTitle: String { private var batterySummaryTitle: String {
NSLocalizedString( String(localized: "battery.bank.header.title", defaultValue: "Battery Bank")
"battery.bank.header.title",
bundle: .main,
value: "Battery Bank",
comment: "Title for the battery bank summary section"
)
} }
private var batteryCountLabel: String { private var batteryCountLabel: String {
NSLocalizedString( String(localized: "battery.bank.metric.count", defaultValue: "Batteries")
"battery.bank.metric.count",
bundle: .main,
value: "Batteries",
comment: "Label for number of batteries metric"
)
} }
private var batteryCapacityLabel: String { private var batteryCapacityLabel: String {
NSLocalizedString( String(localized: "battery.bank.metric.capacity", defaultValue: "Capacity")
"battery.bank.metric.capacity",
bundle: .main,
value: "Capacity",
comment: "Label for total capacity metric"
)
} }
private var batteryUsableCapacityLabel: String { private var batteryUsableCapacityLabel: String {
NSLocalizedString( String(localized: "battery.bank.metric.usable_capacity", defaultValue: "Usable Capacity")
"battery.bank.metric.usable_capacity",
bundle: .main,
value: "Usable Capacity",
comment: "Label for usable capacity metric"
)
} }
private var batteryUsableEnergyLabel: String { private var batteryUsableEnergyLabel: String {
NSLocalizedString( String(localized: "battery.bank.metric.usable_energy", defaultValue: "Usable Energy")
"battery.bank.metric.usable_energy",
bundle: .main,
value: "Usable Energy",
comment: "Label for usable energy metric"
)
} }
private var batteryEmptyTitle: String { private var batteryEmptyTitle: String {
NSLocalizedString( String(localized: "battery.bank.empty.title", defaultValue: "No Batteries Yet")
"battery.bank.empty.title",
bundle: .main,
value: "No Batteries Yet",
comment: "Title shown when no batteries are configured"
)
} }
private var batteryEmptySubtitle: String { private var batteryEmptySubtitle: String {
let format = NSLocalizedString( let format = String(
"battery.bank.empty.subtitle", localized: "battery.bank.empty.subtitle",
tableName: nil, defaultValue: "Tap the plus button to configure a battery for %@."
bundle: .main,
value: "Tap the plus button to configure a battery for %@.",
comment: "Subtitle shown when no batteries are configured"
) )
return String(format: format, system.name) return String(format: format, system.name)
} }
private var batteryEmptyCreateAction: String { private var batteryEmptyCreateAction: String {
NSLocalizedString( String(localized: "battery.overview.empty.create", defaultValue: "Add Battery")
"battery.overview.empty.create",
bundle: .main,
value: "Create Battery",
comment: "Button title to create a new battery"
)
} }
private var chargerSummaryTitle: String { private var chargerSummaryTitle: String {
NSLocalizedString( String(localized: "overview.chargers.header.title", defaultValue: "Charger Overview")
"overview.chargers.header.title",
bundle: .main,
value: "Charger Overview",
comment: "Title for the chargers summary section"
)
} }
private var chargerCountLabel: String { private var chargerCountLabel: String {
NSLocalizedString( String(localized: "chargers.summary.metric.count", defaultValue: "Chargers")
"chargers.summary.metric.count",
bundle: .main,
value: "Chargers",
comment: "Label for number of chargers metric"
)
} }
private var chargerOutputLabel: String { private var chargerOutputLabel: String {
NSLocalizedString( String(localized: "chargers.summary.metric.output", defaultValue: "Output Voltage")
"chargers.summary.metric.output",
bundle: .main,
value: "Output Voltage",
comment: "Label for representative output voltage metric"
)
} }
private var chargerCurrentLabel: String { private var chargerCurrentLabel: String {
NSLocalizedString( String(localized: "chargers.summary.metric.current", defaultValue: "Charge Rate")
"chargers.summary.metric.current",
bundle: .main,
value: "Charge Rate",
comment: "Label for total charger current metric"
)
} }
private var chargerPowerLabel: String { private var chargerPowerLabel: String {
NSLocalizedString( String(localized: "chargers.summary.metric.power", defaultValue: "Charge Power")
"chargers.summary.metric.power",
bundle: .main,
value: "Charge Power",
comment: "Label for total charger power metric"
)
} }
private var chargerEmptyTitle: String { private var chargerEmptyTitle: String {
NSLocalizedString( String(localized: "overview.chargers.empty.title", defaultValue: "No chargers configured yet")
"overview.chargers.empty.title",
bundle: .main,
value: "No chargers configured yet",
comment: "Title shown when no chargers are configured"
)
} }
private var chargerEmptySubtitle: String { private var chargerEmptySubtitle: String {
NSLocalizedString( String(localized: "overview.chargers.empty.subtitle", defaultValue: "Add shore power, DC-DC, or solar chargers to understand your charging capacity.")
"overview.chargers.empty.subtitle",
bundle: .main,
value: "Add shore power, DC-DC, or solar chargers to understand your charging capacity.",
comment: "Subtitle shown when no chargers are configured"
)
} }
private var chargerEmptyCreateAction: String { private var chargerEmptyCreateAction: String {
NSLocalizedString( String(localized: "overview.chargers.empty.create", defaultValue: "Add Charger")
"overview.chargers.empty.create",
bundle: .main,
value: "Add Charger",
comment: "Button title to create a charger from the overview"
)
} }
private var systemOverviewTitle: String { private var systemOverviewTitle: String {
NSLocalizedString( String(localized: "overview.system.header.title", defaultValue: "System Overview")
"overview.system.header.title",
bundle: .main,
value: "System Overview",
comment: "Title for system overview card"
)
} }
private var bomTitle: String { private var bomTitle: String {
NSLocalizedString( String(localized: "overview.bom.title", defaultValue: "Bill of Materials")
"overview.bom.title",
bundle: .main,
value: "Bill of Materials",
comment: "Title for BOM metric card in the system overview"
)
} }
private var bomSubtitle: String { private var bomSubtitle: String {
NSLocalizedString( String(localized: "overview.bom.subtitle", defaultValue: "Tap to review components")
"overview.bom.subtitle",
bundle: .main,
value: "Tap to review components",
comment: "Subtitle describing the BOM metric card interaction"
)
} }
private var bomPlaceholderSummary: String { private var bomPlaceholderSummary: String {
NSLocalizedString( String(localized: "overview.bom.placeholder.short", defaultValue: "Add loads")
"overview.bom.placeholder.short",
bundle: .main,
value: "Add loads",
comment: "Short placeholder shown when no BOM data is available"
)
} }
private var formattedBOMCompletedCount: String? { private var formattedBOMCompletedCount: String? {
@@ -1235,30 +1090,15 @@ struct SystemOverviewView: View {
} }
private var chargeTimeTitle: String { private var chargeTimeTitle: String {
NSLocalizedString( String(localized: "overview.chargetime.title", defaultValue: "Estimated charge time")
"overview.chargetime.title",
bundle: .main,
value: "Estimated charge time",
comment: "Title for the charge time metric card"
)
} }
private var chargeTimeSubtitle: String { private var chargeTimeSubtitle: String {
NSLocalizedString( String(localized: "overview.chargetime.subtitle", defaultValue: "At combined charge rate")
"overview.chargetime.subtitle",
bundle: .main,
value: "At combined charge rate",
comment: "Subtitle describing charge time assumptions"
)
} }
private var chargeTimePlaceholderSummary: String { private var chargeTimePlaceholderSummary: String {
NSLocalizedString( String(localized: "overview.chargetime.placeholder.short", defaultValue: "Add chargers")
"overview.chargetime.placeholder.short",
bundle: .main,
value: "Add chargers",
comment: "Short placeholder shown when charge time cannot be calculated"
)
} }
private var chargeGoalValueText: String? { private var chargeGoalValueText: String? {
@@ -1266,39 +1106,19 @@ struct SystemOverviewView: View {
} }
private var goalPrefix: String { private var goalPrefix: String {
NSLocalizedString( String(localized: "overview.goal.prefix", defaultValue: "Goal")
"overview.goal.prefix",
bundle: .main,
value: "Goal",
comment: "Prefix displayed before goal value"
)
} }
private var runtimeTitle: String { private var runtimeTitle: String {
NSLocalizedString( String(localized: "overview.runtime.title", defaultValue: "Estimated runtime")
"overview.runtime.title",
bundle: .main,
value: "Estimated runtime",
comment: "Title for estimated runtime section"
)
} }
private var runtimeSubtitle: String { private var runtimeSubtitle: String {
NSLocalizedString( String(localized: "overview.runtime.subtitle", defaultValue: "At maximum load draw")
"overview.runtime.subtitle",
bundle: .main,
value: "At current load draw",
comment: "Subtitle describing runtime assumption"
)
} }
private var runtimePlaceholderSummary: String { private var runtimePlaceholderSummary: String {
NSLocalizedString( String(localized: "overview.runtime.placeholder.short", defaultValue: "Add capacity")
"overview.runtime.placeholder.short",
bundle: .main,
value: "Add capacity",
comment: "Short placeholder shown when runtime cannot be calculated"
)
} }
private var runtimeGoalValueText: String? { private var runtimeGoalValueText: String? {
@@ -1311,48 +1131,23 @@ struct SystemOverviewView: View {
} }
private var runtimeGoalSheetTitle: String { private var runtimeGoalSheetTitle: String {
NSLocalizedString( String(localized: "overview.runtime.goal.title", defaultValue: "Runtime Goal")
"overview.runtime.goal.title",
bundle: .main,
value: "Runtime Goal",
comment: "Navigation title for editing the runtime goal"
)
} }
private var chargeGoalSheetTitle: String { private var chargeGoalSheetTitle: String {
NSLocalizedString( String(localized: "overview.chargetime.goal.title", defaultValue: "Charge Goal")
"overview.chargetime.goal.title",
bundle: .main,
value: "Charge Goal",
comment: "Navigation title for editing the charge time goal"
)
} }
private var goalClearTitle: String { private var goalClearTitle: String {
NSLocalizedString( String(localized: "overview.goal.clear", defaultValue: "Remove Goal")
"overview.goal.clear",
bundle: .main,
value: "Remove Goal",
comment: "Button title to clear an active goal"
)
} }
private var goalCancelTitle: String { private var goalCancelTitle: String {
NSLocalizedString( String(localized: "overview.goal.cancel", defaultValue: "Cancel")
"overview.goal.cancel",
bundle: .main,
value: "Cancel",
comment: "Button title to cancel goal editing"
)
} }
private var goalSaveTitle: String { private var goalSaveTitle: String {
NSLocalizedString( String(localized: "overview.goal.save", defaultValue: "Save")
"overview.goal.save",
bundle: .main,
value: "Save",
comment: "Button title to save goal editing"
)
} }
private static let numberFormatter: NumberFormatter = { private static let numberFormatter: NumberFormatter = {
@@ -1405,19 +1200,9 @@ struct SystemOverviewView: View {
var shortLabel: String { var shortLabel: String {
switch self { switch self {
case .voltage: case .voltage:
return NSLocalizedString( return String(localized: "battery.bank.warning.voltage.short", defaultValue: "Voltage")
"battery.bank.warning.voltage.short",
bundle: .main,
value: "Voltage",
comment: "Short label for voltage warning"
)
case .capacity: case .capacity:
return NSLocalizedString( return String(localized: "battery.bank.warning.capacity.short", defaultValue: "Capacity")
"battery.bank.warning.capacity.short",
bundle: .main,
value: "Capacity",
comment: "Short label for capacity warning"
)
} }
} }
} }

View File

@@ -36,9 +36,9 @@ struct SystemBillOfMaterialsPDFExporter {
systemName: systemName, systemName: systemName,
unitSystem: unitSystem unitSystem: unitSystem
) )
let emptyMessage = NSLocalizedString( let emptyMessage = String(
"bom.pdf.placeholder.empty", localized: "bom.pdf.placeholder.empty",
comment: "Message shown in the PDF export when no components are available" defaultValue: "No components available."
) )
drawPlaceholder(in: context.cgContext, text: emptyMessage, at: cursorY) drawPlaceholder(in: context.cgContext, text: emptyMessage, at: cursorY)
} else { } else {
@@ -89,17 +89,17 @@ struct SystemBillOfMaterialsPDFExporter {
let titleFont = UIFont.systemFont(ofSize: isFirstPage ? 26 : 18, weight: .bold) let titleFont = UIFont.systemFont(ofSize: isFirstPage ? 26 : 18, weight: .bold)
let subtitleFont = UIFont.systemFont(ofSize: isFirstPage ? 16 : 12, weight: .medium) let subtitleFont = UIFont.systemFont(ofSize: isFirstPage ? 16 : 12, weight: .medium)
let title = isFirstPage let title = isFirstPage
? NSLocalizedString( ? String(
"bom.pdf.header.title", localized: "bom.pdf.header.title",
comment: "Primary title shown at the top of the BOM PDF" defaultValue: "System Bill of Materials"
) )
: systemName : systemName
let subtitle: String let subtitle: String
if isFirstPage { if isFirstPage {
let format = NSLocalizedString( let format = String(
"bom.pdf.header.subtitle", localized: "bom.pdf.header.subtitle",
comment: "Subtitle format combining system name and unit system for the BOM PDF" defaultValue: "%@ • %@"
) )
subtitle = String( subtitle = String(
format: format, format: format,
@@ -108,9 +108,9 @@ struct SystemBillOfMaterialsPDFExporter {
unitSystem.displayName unitSystem.displayName
) )
} else { } else {
let format = NSLocalizedString( let format = String(
"bom.pdf.header.inline", localized: "bom.pdf.header.inline",
comment: "Subtitle describing the active unit system on subsequent PDF pages" defaultValue: "Unit System: %@"
) )
subtitle = String( subtitle = String(
format: format, format: format,
@@ -286,9 +286,9 @@ struct SystemBillOfMaterialsPDFExporter {
.font: footerFont, .font: footerFont,
.foregroundColor: tertiaryTextColor .foregroundColor: tertiaryTextColor
] ]
let format = NSLocalizedString( let format = String(
"bom.pdf.page.number", localized: "bom.pdf.page.number",
comment: "Format string for the PDF page number footer" defaultValue: "Page %d"
) )
let text = String(format: format, locale: Locale.current, pageIndex) let text = String(format: format, locale: Locale.current, pageIndex)
let size = text.size(withAttributes: attributes) let size = text.size(withAttributes: attributes)

View File

@@ -40,60 +40,30 @@ struct SystemBillOfMaterialsView: View {
var title: String { var title: String {
switch self { switch self {
case .components: case .components:
return NSLocalizedString( return String(localized: "bom.category.components.title", defaultValue: "Components & Chargers")
"bom.category.components.title",
comment: "Section title for core components and chargers"
)
case .batteries: case .batteries:
return NSLocalizedString( return String(localized: "bom.category.batteries.title", defaultValue: "Batteries")
"bom.category.batteries.title",
comment: "Section title for batteries"
)
case .cables: case .cables:
return NSLocalizedString( return String(localized: "bom.category.cables.title", defaultValue: "Cables")
"bom.category.cables.title",
comment: "Section title for power cables"
)
case .fuses: case .fuses:
return NSLocalizedString( return String(localized: "bom.category.fuses.title", defaultValue: "Fuses")
"bom.category.fuses.title",
comment: "Section title for fuses and holders"
)
case .accessories: case .accessories:
return NSLocalizedString( return String(localized: "bom.category.accessories.title", defaultValue: "Accessories")
"bom.category.accessories.title",
comment: "Section title for accessory hardware"
)
} }
} }
var subtitle: String { var subtitle: String {
switch self { switch self {
case .components: case .components:
return NSLocalizedString( return String(localized: "bom.category.components.subtitle", defaultValue: "Primary devices, controllers, and charging gear.")
"bom.category.components.subtitle",
comment: "Subtitle describing the components section"
)
case .batteries: case .batteries:
return NSLocalizedString( return String(localized: "bom.category.batteries.subtitle", defaultValue: "House banks and storage.")
"bom.category.batteries.subtitle",
comment: "Subtitle describing the batteries section"
)
case .cables: case .cables:
return NSLocalizedString( return String(localized: "bom.category.cables.subtitle", defaultValue: "Sized power runs for every circuit.")
"bom.category.cables.subtitle",
comment: "Subtitle describing the cables section"
)
case .fuses: case .fuses:
return NSLocalizedString( return String(localized: "bom.category.fuses.subtitle", defaultValue: "Circuit protection and holders.")
"bom.category.fuses.subtitle",
comment: "Subtitle describing the fuses section"
)
case .accessories: case .accessories:
return NSLocalizedString( return String(localized: "bom.category.accessories.subtitle", defaultValue: "Fuses, lugs, and supporting hardware.")
"bom.category.accessories.subtitle",
comment: "Subtitle describing the accessories section"
)
} }
} }
@@ -165,44 +135,26 @@ struct SystemBillOfMaterialsView: View {
if let scale = quantityScale { if let scale = quantityScale {
guard let unit = quantifiedDetailContext else { return nil } guard let unit = quantifiedDetailContext else { return nil }
let value = Double(quantity) / scale let value = Double(quantity) / scale
let format = NSLocalizedString( let format = String(localized: "bom.quantity.cable.badge", defaultValue: "%1$.1f %2$@ · %3$@")
"bom.quantity.cable.badge",
comment: "Metric text for total cable length including cross section"
)
return String(format: format, locale: Locale.current, value, unit, quantifiedDetailSecondaryContext ?? "") return String(format: format, locale: Locale.current, value, unit, quantifiedDetailSecondaryContext ?? "")
} else if quantity > 1 { } else if quantity > 1 {
if let fuseRating = quantifiedDetailSecondaryContext, let amps = Int(fuseRating) { if let fuseRating = quantifiedDetailSecondaryContext, let amps = Int(fuseRating) {
let format = NSLocalizedString( let format = String(localized: "bom.quantity.fuse.badge", defaultValue: "%1$d× · %2$d A")
"bom.quantity.fuse.badge",
comment: "Metric text for consolidated fuses"
)
return String(format: format, quantity, amps) return String(format: format, quantity, amps)
} }
if let gauge = quantifiedDetailContext { if let gauge = quantifiedDetailContext {
let format = NSLocalizedString( let format = String(localized: "bom.quantity.terminal.badge", defaultValue: "%1$d× · %2$@")
"bom.quantity.terminal.badge",
comment: "Metric text for consolidated terminals"
)
return String(format: format, quantity, gauge) return String(format: format, quantity, gauge)
} }
let format = NSLocalizedString( let format = String(localized: "bom.quantity.count.badge", defaultValue: "%d×")
"bom.quantity.count.badge",
comment: "Metric text for counted items"
)
return String(format: format, quantity) return String(format: format, quantity)
} }
if let fuseRating = quantifiedDetailSecondaryContext, let amps = Int(fuseRating) { if let fuseRating = quantifiedDetailSecondaryContext, let amps = Int(fuseRating) {
let format = NSLocalizedString( let format = String(localized: "bom.quantity.fuse.badge", defaultValue: "%1$d× · %2$d A")
"bom.quantity.fuse.badge",
comment: "Metric text for consolidated fuses"
)
return String(format: format, 1, amps) return String(format: format, 1, amps)
} }
if let gauge = quantifiedDetailContext, !gauge.isEmpty { if let gauge = quantifiedDetailContext, !gauge.isEmpty {
let format = NSLocalizedString( let format = String(localized: "bom.quantity.single.badge", defaultValue: "1× • %@")
"bom.quantity.single.badge",
comment: "Metric text when quantity is one but should be explicit"
)
return String(format: format, gauge) return String(format: format, gauge)
} }
return nil return nil
@@ -313,10 +265,7 @@ struct SystemBillOfMaterialsView: View {
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.navigationTitle( .navigationTitle(
String( String(
format: NSLocalizedString( format: String(localized: "bom.navigation.title.system", defaultValue: "BOM %@"),
"bom.navigation.title.system",
comment: "Navigation title for the bill of materials view"
),
locale: Locale.current, locale: Locale.current,
systemName systemName
) )
@@ -337,10 +286,7 @@ struct SystemBillOfMaterialsView: View {
.progressViewStyle(.circular) .progressViewStyle(.circular)
} else { } else {
Label( Label(
NSLocalizedString( String(localized: "bom.export.pdf.button", defaultValue: "Export PDF"),
"bom.export.pdf.button",
comment: "Button title for exporting the BOM as a PDF document"
),
systemImage: "square.and.arrow.up" systemImage: "square.and.arrow.up"
) )
} }
@@ -361,18 +307,12 @@ struct SystemBillOfMaterialsView: View {
.alert(item: $exportError) { error in .alert(item: $exportError) { error in
Alert( Alert(
title: Text( title: Text(
NSLocalizedString( String(localized: "bom.export.pdf.error.title", defaultValue: "Export Failed")
"bom.export.pdf.error.title",
comment: "Title for the alert shown when a PDF export fails"
)
), ),
message: Text(error.message), message: Text(error.message),
dismissButton: .default( dismissButton: .default(
Text( Text(
NSLocalizedString( String(localized: "generic.ok", defaultValue: "OK")
"generic.ok",
comment: "Default acknowledgement button title"
)
) )
) )
) )
@@ -382,10 +322,7 @@ struct SystemBillOfMaterialsView: View {
private func exportBillOfMaterialsPDF() { private func exportBillOfMaterialsPDF() {
if categorySections.isEmpty { if categorySections.isEmpty {
exportError = ExportError( exportError = ExportError(
message: NSLocalizedString( message: String(localized: "bom.export.pdf.error.empty", defaultValue: "Add at least one component before exporting.")
"bom.export.pdf.error.empty",
comment: "Error message shown when attempting to export a PDF without any components"
)
) )
return return
} }
@@ -436,11 +373,9 @@ struct SystemBillOfMaterialsView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
let accessibilityLabel: String = { let accessibilityLabel: String = {
let formatKey = isCompleted ? "bom.accessibility.mark.incomplete" : "bom.accessibility.mark.complete" let format = isCompleted
let format = NSLocalizedString( ? String(localized: "bom.accessibility.mark.incomplete", defaultValue: "Mark %@ incomplete")
formatKey, : String(localized: "bom.accessibility.mark.complete", defaultValue: "Mark %@ complete")
comment: "Accessibility label instructing VoiceOver to toggle a BOM item"
)
return String.localizedStringWithFormat(format, item.title) return String.localizedStringWithFormat(format, item.title)
}() }()
@@ -620,9 +555,9 @@ struct SystemBillOfMaterialsView: View {
if unitSystem == .imperial { if unitSystem == .imperial {
let awg = awgFromCrossSection(load.crossSection) let awg = awgFromCrossSection(load.crossSection)
if awg > 0 { if load.crossSection > 0 {
crossSectionLabel = String(format: "AWG %.0f", awg) crossSectionLabel = "AWG \(ElectricalCalculations.formatAWG(awg))"
gaugeQuery = String(format: "AWG %.0f", awg) gaugeQuery = "AWG \(ElectricalCalculations.formatAWG(awg))"
} else { } else {
crossSectionLabel = unknownSizeLabel crossSectionLabel = unknownSizeLabel
gaugeQuery = "battery cable" gaugeQuery = "battery cable"
@@ -641,34 +576,28 @@ struct SystemBillOfMaterialsView: View {
let powerDetail = String(format: "%.0f W @ %.1f V", calculatedPower, load.voltage) let powerDetail = String(format: "%.0f W @ %.1f V", calculatedPower, load.voltage)
let fuseRating = recommendedFuse(for: load) let fuseRating = recommendedFuse(for: load)
let fuseDetailFormat = NSLocalizedString( let fuseDetailFormat = String(localized: "bom.fuse.detail", defaultValue: "Inline holder and %dA fuse")
"bom.fuse.detail",
comment: "Description for the fuse item in the BOM list"
)
let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating) let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating)
let terminalCount = 4 let terminalCount = 4
let cableShoesDetailFormat = NSLocalizedString( let cableShoesDetailFormat = String(localized: "bom.terminals.detail", defaultValue: "Ring or spade terminals sized for %@ wiring")
"bom.terminals.detail",
comment: "Description for the cable terminals item in the BOM list"
)
let cableShoesDetail = String.localizedStringWithFormat( let cableShoesDetail = String.localizedStringWithFormat(
cableShoesDetailFormat, cableShoesDetailFormat,
crossSectionLabel.lowercased() crossSectionLabel.lowercased()
) )
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) } let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
let deviceFallbackFormat = NSLocalizedString("bom.search.device.fallback", comment: "Amazon search query fallback for a DC device") let deviceFallbackFormat = String(localized: "bom.search.device.fallback", defaultValue: "DC device %.0fW %.0fV")
let deviceQuery = load.name.isEmpty let deviceQuery = load.name.isEmpty
? String(format: deviceFallbackFormat, calculatedPower, load.voltage) ? String(format: deviceFallbackFormat, calculatedPower, load.voltage)
: load.name : load.name
let redCableSearchFormat = NSLocalizedString("bom.search.cable.red", comment: "Amazon search query for red battery cable") let redCableSearchFormat = String(localized: "bom.search.cable.red", defaultValue: "%@ red battery cable")
let redCableQuery = String(format: redCableSearchFormat, gaugeQuery) let redCableQuery = String(format: redCableSearchFormat, gaugeQuery)
let blackCableSearchFormat = NSLocalizedString("bom.search.cable.black", comment: "Amazon search query for black battery cable") let blackCableSearchFormat = String(localized: "bom.search.cable.black", defaultValue: "%@ black battery cable")
let blackCableQuery = String(format: blackCableSearchFormat, gaugeQuery) let blackCableQuery = String(format: blackCableSearchFormat, gaugeQuery)
let fuseSearchFormat = NSLocalizedString("bom.search.fuse", comment: "Amazon search query for inline fuse holder") let fuseSearchFormat = String(localized: "bom.search.fuse", defaultValue: "inline fuse holder %dA")
let fuseQuery = String(format: fuseSearchFormat, fuseRating) let fuseQuery = String(format: fuseSearchFormat, fuseRating)
let terminalSearchFormat = NSLocalizedString("bom.search.terminals", comment: "Amazon search query for cable shoes") let terminalSearchFormat = String(localized: "bom.search.terminals", defaultValue: "%@ cable shoes")
let terminalQuery = String(format: terminalSearchFormat, gaugeQuery) let terminalQuery = String(format: terminalSearchFormat, gaugeQuery)
let componentStorageKey = Self.storageKey(for: component, itemID: "component") let componentStorageKey = Self.storageKey(for: component, itemID: "component")
@@ -775,19 +704,13 @@ struct SystemBillOfMaterialsView: View {
private func batteryItems(for battery: SavedBattery) -> [Item] { private func batteryItems(for battery: SavedBattery) -> [Item] {
let component: Component = .battery(battery) let component: Component = .battery(battery)
let usableCapacityLabel = NSLocalizedString( let usableCapacityLabel = String(localized: "battery.bank.metric.usable_capacity", defaultValue: "Usable Capacity")
"battery.bank.metric.usable_capacity",
comment: "Label describing usable battery capacity"
)
let capacityLabel = String(format: "%.0f Ah @ %.1f V", battery.capacityAmpHours, battery.nominalVoltage) let capacityLabel = String(format: "%.0f Ah @ %.1f V", battery.capacityAmpHours, battery.nominalVoltage)
let usableLabel = String(format: "%@: %.0f Ah", usableCapacityLabel, battery.usableCapacityAmpHours) let usableLabel = String(format: "%@: %.0f Ah", usableCapacityLabel, battery.usableCapacityAmpHours)
let detail = [capacityLabel, battery.chemistry.displayName, usableLabel].joined(separator: "") let detail = [capacityLabel, battery.chemistry.displayName, usableLabel].joined(separator: "")
let capacityQuery = max(1, Int(round(battery.capacityAmpHours))) let capacityQuery = max(1, Int(round(battery.capacityAmpHours)))
let voltageQuery = max(1, Int(round(battery.nominalVoltage))) let voltageQuery = max(1, Int(round(battery.nominalVoltage)))
let batterySearchFormat = NSLocalizedString( let batterySearchFormat = String(localized: "bom.search.battery", defaultValue: "%dAh %dV %@ battery")
"bom.search.battery",
comment: "Amazon search query for a battery"
)
let query = String(format: batterySearchFormat, capacityQuery, voltageQuery, battery.chemistry.displayName) let query = String(format: batterySearchFormat, capacityQuery, voltageQuery, battery.chemistry.displayName)
let affiliateURL = battery.affiliateURLString.flatMap { URL(string: $0) } let affiliateURL = battery.affiliateURLString.flatMap { URL(string: $0) }
let storageKey = Self.storageKey(for: component, itemID: "battery") let storageKey = Self.storageKey(for: component, itemID: "battery")
@@ -933,15 +856,16 @@ struct SystemBillOfMaterialsView: View {
completedItemIDs = Set(loadKeys + batteryKeys + chargerKeys) completedItemIDs = Set(loadKeys + batteryKeys + chargerKeys)
} }
private func recommendedFuse(for load: SavedLoad) -> Int { private func recommendedFuse(for load: SavedLoad) -> String {
ElectricalCalculations.recommendedFuse(forCurrent: load.current) let fuse = ElectricalCalculations.recommendedFuse(forCurrent: load.current)
return fuse == fuse.rounded() ? String(format: "%.0f", fuse) : String(format: "%.1f", fuse)
} }
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double { private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
let mapping: [(awg: Double, area: Double)] = [ let mapping: [(awg: Double, area: Double)] = [
(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31), (10, 5.26), (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), (1, 42.4), (0, 53.5), (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6), (1, 42.4), (-1, 53.5),
(00, 67.4), (000, 85.0), (0000, 107.0) (-2, 67.4), (-3, 85.0), (-4, 107.0)
] ]
guard crossSectionMM2 > 0 else { return 0 } guard crossSectionMM2 > 0 else { return 0 }
@@ -954,10 +878,7 @@ struct SystemBillOfMaterialsView: View {
} }
private var footerMessage: String { private var footerMessage: String {
NSLocalizedString( String(localized: "affiliate.disclaimer", defaultValue: "Purchases through affiliate links may support VoltPlan.")
"affiliate.disclaimer",
comment: "Footer note reminding users that affiliate purchases may support the app"
)
} }
private var dateFormatter: DateFormatter { private var dateFormatter: DateFormatter {

View File

@@ -98,11 +98,9 @@ struct SystemComponentsPersistence {
existingBatteries: [SavedBattery], existingBatteries: [SavedBattery],
existingChargers: [SavedCharger] existingChargers: [SavedCharger]
) -> BatteryConfiguration { ) -> BatteryConfiguration {
let defaultName = NSLocalizedString( let defaultName = String(
"battery.editor.default_name", localized: "battery.editor.default_name",
bundle: .main, defaultValue: "New Battery"
value: "New Battery",
comment: "Default name when configuring a new battery"
) )
let batteryName = uniqueName( let batteryName = uniqueName(
startingWith: defaultName, startingWith: defaultName,
@@ -124,11 +122,9 @@ struct SystemComponentsPersistence {
existingBatteries: [SavedBattery], existingBatteries: [SavedBattery],
existingChargers: [SavedCharger] existingChargers: [SavedCharger]
) -> ChargerConfiguration { ) -> ChargerConfiguration {
let defaultName = NSLocalizedString( let defaultName = String(
"charger.editor.default_name", localized: "charger.editor.default_name",
bundle: .main, defaultValue: "New Charger"
value: "New Charger",
comment: "Default name when configuring a new charger"
) )
let chargerName = uniqueName( let chargerName = uniqueName(
startingWith: defaultName, startingWith: defaultName,

View File

@@ -472,10 +472,7 @@ struct SystemsView: View {
formattedPower = String(format: "%.0fW", totalPower) formattedPower = String(format: "%.0fW", totalPower)
} }
let format = NSLocalizedString( let format = String(localized: "system.list.component.summary", defaultValue: "%#@component_count@ • %@")
"system.list.component.summary",
comment: "Summary showing number of components and the total power"
)
return String.localizedStringWithFormat(format, count, formattedPower) return String.localizedStringWithFormat(format, count, formattedPower)
} }
@@ -499,12 +496,10 @@ struct SystemsView: View {
private func keywords(for localizationKey: String, fallback: [String]) -> [String] { private func keywords(for localizationKey: String, fallback: [String]) -> [String] {
let fallbackValue = fallback.joined(separator: ",") let fallbackValue = fallback.joined(separator: ",")
let localizedKeywords = NSLocalizedString( let localizedKeywords = Bundle.main.localizedString(
localizationKey, forKey: localizationKey,
tableName: nil,
bundle: .main,
value: fallbackValue, value: fallbackValue,
comment: "" table: nil
) )
let separators = CharacterSet(charactersIn: ",;") let separators = CharacterSet(charactersIn: ",;")
let components = localizedKeywords let components = localizedKeywords

View File

@@ -207,7 +207,8 @@ extension UITestSampleData {
iconName: "bolt.badge.clock", iconName: "bolt.badge.clock",
colorName: "blue", colorName: "blue",
system: adventureVan, system: adventureVan,
identifier: "sample.charger.dcdc" identifier: "sample.charger.dcdc",
powerSourceType: "alternator"
) )
alternatorCharger.timestamp = Date(timeIntervalSinceReferenceDate: 1350) alternatorCharger.timestamp = Date(timeIntervalSinceReferenceDate: 1350)

View File

@@ -84,7 +84,7 @@
"battery.bank.status.voltage.title" = "Spannungsabweichung"; "battery.bank.status.voltage.title" = "Spannungsabweichung";
"battery.bank.warning.capacity.short" = "Kapazität"; "battery.bank.warning.capacity.short" = "Kapazität";
"battery.bank.warning.voltage.short" = "Spannung"; "battery.bank.warning.voltage.short" = "Spannung";
"battery.editor.advanced.usable_capacity.footer_default" = "Standardwert %@ basierend auf der Chemie."; "battery.editor.advanced.usable_capacity.footer_default" = "Standard %@ basierend auf der Chemie.";
"battery.editor.advanced.usable_capacity.footer_override" = "Überschreibung aktiv. Chemie-Standard bleibt %@."; "battery.editor.advanced.usable_capacity.footer_override" = "Überschreibung aktiv. Chemie-Standard bleibt %@.";
"battery.editor.advanced.charge_voltage.helper" = "Lege die maximal empfohlene Ladespannung fest."; "battery.editor.advanced.charge_voltage.helper" = "Lege die maximal empfohlene Ladespannung fest.";
"battery.editor.advanced.cutoff_voltage.helper" = "Lege die minimale sichere Entladespannung fest."; "battery.editor.advanced.cutoff_voltage.helper" = "Lege die minimale sichere Entladespannung fest.";
@@ -442,6 +442,7 @@
"charger.source.solar" = "Solar"; "charger.source.solar" = "Solar";
"charger.source.wind" = "Wind"; "charger.source.wind" = "Wind";
"charger.source.generator" = "Generator"; "charger.source.generator" = "Generator";
"charger.source.alternator" = "Lichtmaschine";
// MARK: - Share Menu // MARK: - Share Menu
"overview.share.diagram" = "Schaltplan"; "overview.share.diagram" = "Schaltplan";

View File

@@ -207,7 +207,7 @@
"overview.loads.empty.title" = "Aún no hay cargas configuradas"; "overview.loads.empty.title" = "Aún no hay cargas configuradas";
"overview.loads.empty.subtitle" = "Añade cargas para obtener recomendaciones de cables y fusibles adaptadas a este sistema."; "overview.loads.empty.subtitle" = "Añade cargas para obtener recomendaciones de cables y fusibles adaptadas a este sistema.";
"overview.runtime.title" = "Autonomía estimada"; "overview.runtime.title" = "Autonomía estimada";
"overview.runtime.subtitle" = "Con la carga actual"; "overview.runtime.subtitle" = "Con la carga máxima";
"overview.runtime.unavailable" = "Añade capacidad de batería y potencia de carga para estimar la autonomía."; "overview.runtime.unavailable" = "Añade capacidad de batería y potencia de carga para estimar la autonomía.";
"overview.bom.title" = "Lista de materiales"; "overview.bom.title" = "Lista de materiales";
"overview.bom.subtitle" = "Pulsa para revisar los componentes"; "overview.bom.subtitle" = "Pulsa para revisar los componentes";
@@ -273,7 +273,7 @@
"battery.editor.slider.temperature_range.max" = "Máximo"; "battery.editor.slider.temperature_range.max" = "Máximo";
"battery.editor.section.advanced" = "Avanzado"; "battery.editor.section.advanced" = "Avanzado";
"battery.editor.button.reset_default" = "Restablecer"; "battery.editor.button.reset_default" = "Restablecer";
"battery.editor.advanced.usable_capacity.footer_default" = "Valor predeterminado %@ basado en la química."; "battery.editor.advanced.usable_capacity.footer_default" = "Predeterminado %@ según la química.";
"battery.editor.advanced.usable_capacity.footer_override" = "Sobrescritura activa. El valor predeterminado por química sigue siendo %@."; "battery.editor.advanced.usable_capacity.footer_override" = "Sobrescritura activa. El valor predeterminado por química sigue siendo %@.";
"battery.editor.advanced.charge_voltage.helper" = "Establece el voltaje máximo de carga recomendado."; "battery.editor.advanced.charge_voltage.helper" = "Establece el voltaje máximo de carga recomendado.";
"battery.editor.advanced.cutoff_voltage.helper" = "Establece el voltaje mínimo seguro de descarga."; "battery.editor.advanced.cutoff_voltage.helper" = "Establece el voltaje mínimo seguro de descarga.";
@@ -404,6 +404,7 @@
"charger.source.solar" = "Solar"; "charger.source.solar" = "Solar";
"charger.source.wind" = "Eólica"; "charger.source.wind" = "Eólica";
"charger.source.generator" = "Generador"; "charger.source.generator" = "Generador";
"charger.source.alternator" = "Alternador";
// MARK: - Share Menu // MARK: - Share Menu
"overview.share.diagram" = "Diagrama de cableado"; "overview.share.diagram" = "Diagrama de cableado";

View File

@@ -207,7 +207,7 @@
"overview.loads.empty.title" = "Aucune charge configurée pour l'instant"; "overview.loads.empty.title" = "Aucune charge configurée pour l'instant";
"overview.loads.empty.subtitle" = "Ajoutez des charges pour obtenir des recommandations de câbles et de fusibles adaptées à ce système."; "overview.loads.empty.subtitle" = "Ajoutez des charges pour obtenir des recommandations de câbles et de fusibles adaptées à ce système.";
"overview.runtime.title" = "Autonomie estimée"; "overview.runtime.title" = "Autonomie estimée";
"overview.runtime.subtitle" = "Avec la charge actuelle"; "overview.runtime.subtitle" = "À charge maximale";
"overview.runtime.unavailable" = "Ajoutez la capacité de la batterie et la puissance consommée pour estimer lautonomie."; "overview.runtime.unavailable" = "Ajoutez la capacité de la batterie et la puissance consommée pour estimer lautonomie.";
"overview.bom.title" = "Liste de matériel"; "overview.bom.title" = "Liste de matériel";
"overview.bom.subtitle" = "Touchez pour consulter les composants"; "overview.bom.subtitle" = "Touchez pour consulter les composants";
@@ -273,7 +273,7 @@
"battery.editor.slider.temperature_range.max" = "Maximum"; "battery.editor.slider.temperature_range.max" = "Maximum";
"battery.editor.section.advanced" = "Avancé"; "battery.editor.section.advanced" = "Avancé";
"battery.editor.button.reset_default" = "Réinitialiser"; "battery.editor.button.reset_default" = "Réinitialiser";
"battery.editor.advanced.usable_capacity.footer_default" = "Valeur par défaut %@ selon la chimie."; "battery.editor.advanced.usable_capacity.footer_default" = "Par défaut %@ selon la chimie.";
"battery.editor.advanced.usable_capacity.footer_override" = "Remplacement actif. La valeur par défaut liée à la chimie reste %@."; "battery.editor.advanced.usable_capacity.footer_override" = "Remplacement actif. La valeur par défaut liée à la chimie reste %@.";
"battery.editor.advanced.charge_voltage.helper" = "Définissez la tension de charge maximale recommandée."; "battery.editor.advanced.charge_voltage.helper" = "Définissez la tension de charge maximale recommandée.";
"battery.editor.advanced.cutoff_voltage.helper" = "Définissez la tension minimale de décharge sûre."; "battery.editor.advanced.cutoff_voltage.helper" = "Définissez la tension minimale de décharge sûre.";
@@ -404,6 +404,7 @@
"charger.source.solar" = "Solaire"; "charger.source.solar" = "Solaire";
"charger.source.wind" = "Éolienne"; "charger.source.wind" = "Éolienne";
"charger.source.generator" = "Groupe électrogène"; "charger.source.generator" = "Groupe électrogène";
"charger.source.alternator" = "Alternateur";
// MARK: - Share Menu // MARK: - Share Menu
"overview.share.diagram" = "Schéma de câblage"; "overview.share.diagram" = "Schéma de câblage";

View File

@@ -207,7 +207,7 @@
"overview.loads.empty.title" = "Nog geen lasten geconfigureerd"; "overview.loads.empty.title" = "Nog geen lasten geconfigureerd";
"overview.loads.empty.subtitle" = "Voeg lasten toe om kabel- en zekeringsadviezen te krijgen die zijn afgestemd op dit systeem."; "overview.loads.empty.subtitle" = "Voeg lasten toe om kabel- en zekeringsadviezen te krijgen die zijn afgestemd op dit systeem.";
"overview.runtime.title" = "Geschatte looptijd"; "overview.runtime.title" = "Geschatte looptijd";
"overview.runtime.subtitle" = "Bij huidige belasting"; "overview.runtime.subtitle" = "Bij maximale belasting";
"overview.runtime.unavailable" = "Voeg batterijcapaciteit en vermogensvraag toe om de looptijd te berekenen."; "overview.runtime.unavailable" = "Voeg batterijcapaciteit en vermogensvraag toe om de looptijd te berekenen.";
"overview.bom.title" = "Stuklijst"; "overview.bom.title" = "Stuklijst";
"overview.bom.subtitle" = "Tik om componenten te bekijken"; "overview.bom.subtitle" = "Tik om componenten te bekijken";
@@ -273,8 +273,8 @@
"battery.editor.slider.temperature_range.max" = "Maximum"; "battery.editor.slider.temperature_range.max" = "Maximum";
"battery.editor.section.advanced" = "Geavanceerd"; "battery.editor.section.advanced" = "Geavanceerd";
"battery.editor.button.reset_default" = "Resetten"; "battery.editor.button.reset_default" = "Resetten";
"battery.editor.advanced.usable_capacity.footer_default" = "Standaardwaarde %@ op basis van de chemie."; "battery.editor.advanced.usable_capacity.footer_default" = "Standaard %@ op basis van de chemie.";
"battery.editor.advanced.usable_capacity.footer_override" = "Handmatige override actief. Chemische standaard blijft %@."; "battery.editor.advanced.usable_capacity.footer_override" = "Override actief. Chemische standaard blijft %@.";
"battery.editor.advanced.charge_voltage.helper" = "Stel de maximaal aanbevolen laadspanning in."; "battery.editor.advanced.charge_voltage.helper" = "Stel de maximaal aanbevolen laadspanning in.";
"battery.editor.advanced.cutoff_voltage.helper" = "Stel de minimale veilige ontlaadspanning in."; "battery.editor.advanced.cutoff_voltage.helper" = "Stel de minimale veilige ontlaadspanning in.";
"battery.editor.advanced.temperature_range.helper" = "Bepaal het aanbevolen temperatuurbereik voor gebruik."; "battery.editor.advanced.temperature_range.helper" = "Bepaal het aanbevolen temperatuurbereik voor gebruik.";
@@ -404,6 +404,7 @@
"charger.source.solar" = "Zonne-energie"; "charger.source.solar" = "Zonne-energie";
"charger.source.wind" = "Wind"; "charger.source.wind" = "Wind";
"charger.source.generator" = "Generator"; "charger.source.generator" = "Generator";
"charger.source.alternator" = "Dynamo";
// MARK: - Share Menu // MARK: - Share Menu
"overview.share.diagram" = "Bedradingsschema"; "overview.share.diagram" = "Bedradingsschema";

View File

@@ -10,7 +10,12 @@ import Testing
struct CableTests { struct CableTests {
// MARK: - Core Formula Verification
/// Formula: A = (2 × I × L × ρ) / V_drop
/// With ρ = 0.017 Ω·mm²/m, max voltage drop = 5%
@Test func metricWireSizingUsesNearestStandardSize() async throws { @Test func metricWireSizingUsesNearestStandardSize() async throws {
// 10m, 5A, 12V minCS = (2×5×10×0.017)/(12×0.05) = 2.833mm² rounds up to 4.0mm²
let crossSection = ElectricalCalculations.recommendedCrossSection( let crossSection = ElectricalCalculations.recommendedCrossSection(
length: 10, length: 10,
current: 5, current: 5,
@@ -19,6 +24,7 @@ struct CableTests {
) )
#expect(crossSection == 4.0) #expect(crossSection == 4.0)
// V_drop = (2×5×10×0.017)/4.0 = 0.425V
let voltageDrop = ElectricalCalculations.voltageDrop( let voltageDrop = ElectricalCalculations.voltageDrop(
length: 10, length: 10,
current: 5, current: 5,
@@ -27,6 +33,7 @@ struct CableTests {
) )
#expect(abs(voltageDrop - 0.425) < 0.001) #expect(abs(voltageDrop - 0.425) < 0.001)
// 0.425/12 × 100 = 3.5417%
let dropPercentage = ElectricalCalculations.voltageDropPercentage( let dropPercentage = ElectricalCalculations.voltageDropPercentage(
length: 10, length: 10,
current: 5, current: 5,
@@ -35,6 +42,7 @@ struct CableTests {
) )
#expect(abs(dropPercentage - 3.5417) < 0.001) #expect(abs(dropPercentage - 3.5417) < 0.001)
// P_loss = I × V_drop = 5 × 0.425 = 2.125W
let powerLoss = ElectricalCalculations.powerLoss( let powerLoss = ElectricalCalculations.powerLoss(
length: 10, length: 10,
current: 5, current: 5,
@@ -44,42 +52,364 @@ struct CableTests {
#expect(abs(powerLoss - 2.125) < 0.001) #expect(abs(powerLoss - 2.125) < 0.001)
} }
/// Imperial test: length is always in meters (25 ft = 7.62 m), unitSystem controls AWG output
@Test func imperialWireSizingMatchesExpectedGauge() async throws { @Test func imperialWireSizingMatchesExpectedGauge() async throws {
let lengthInMeters = 25.0 * 0.3048 // 25 ft = 7.62 m
let awg = ElectricalCalculations.recommendedCrossSection( let awg = ElectricalCalculations.recommendedCrossSection(
length: 25, length: lengthInMeters,
current: 15, current: 15,
voltage: 120, voltage: 120,
unitSystem: .imperial unitSystem: .imperial
) )
// minCS = (2×15×7.62×0.017)/(120×0.05) = 0.648mm² AWG 18 (0.823mm²)
#expect(awg == 18.0) #expect(awg == 18.0)
let voltageDrop = ElectricalCalculations.voltageDrop( let voltageDrop = ElectricalCalculations.voltageDrop(
length: 25, length: lengthInMeters,
current: 15, current: 15,
voltage: 120, voltage: 120,
unitSystem: .imperial unitSystem: .imperial
) )
// (2×15×7.62×0.017)/0.823 = 4.722V
#expect(abs(voltageDrop - 4.722) < 0.01) #expect(abs(voltageDrop - 4.722) < 0.01)
let dropPercentage = ElectricalCalculations.voltageDropPercentage( let dropPercentage = ElectricalCalculations.voltageDropPercentage(
length: 25, length: lengthInMeters,
current: 15, current: 15,
voltage: 120, voltage: 120,
unitSystem: .imperial unitSystem: .imperial
) )
// 4.722/120 × 100 = 3.935%
#expect(abs(dropPercentage - 3.935) < 0.01) #expect(abs(dropPercentage - 3.935) < 0.01)
let powerLoss = ElectricalCalculations.powerLoss( let powerLoss = ElectricalCalculations.powerLoss(
length: 25, length: lengthInMeters,
current: 15, current: 15,
voltage: 120, voltage: 120,
unitSystem: .imperial unitSystem: .imperial
) )
// 15 × 4.722 = 70.83W
#expect(abs(powerLoss - 70.83) < 0.05) #expect(abs(powerLoss - 70.83) < 0.05)
} }
// MARK: - Fuse Sizing
@Test func recommendedFuseRoundsUpToNearestStandardSize() async throws { @Test func recommendedFuseRoundsUpToNearestStandardSize() async throws {
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 7.2) == 10) // 7.2A × 1.25 = 9.0 next fuse 9 = 10A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 59.0) == 80) #expect(ElectricalCalculations.recommendedFuse(forCurrent: 7.2) == 10.0)
// 59A × 1.25 = 73.75 ceil = 74 next fuse 74 = 80A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 59.0) == 80.0)
}
@Test func fuseAt125PercentOfCurrent() async throws {
// 10A × 1.25 = 12.5 ceil = 13 next fuse = 15A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 10.0) == 15.0)
// 20A × 1.25 = 25 next fuse = 25A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 20.0) == 25.0)
// 1A × 1.25 = 1.25 ceil = 2 next fuse = 2A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 1.0) == 2.0)
// 4A × 1.25 = 5 next fuse = 5A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 4.0) == 5.0)
// 100A × 1.25 = 125 next fuse = 125A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 100.0) == 125.0)
}
@Test func fuseSelectsHalfAmpereSizes() async throws {
// 5A × 1.25 = 6.25 ceil = 7 next fuse 7 = 7.5A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 5.0) == 7.5)
// 6A × 1.25 = 7.5 ceil = 8 next fuse 8 = 10A
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 6.0) == 10.0)
}
@Test func fuseForZeroCurrent() async throws {
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 0) == 1.0)
}
// MARK: - Metric Cross-Section Boundaries
@Test func metricCrossSectionSelectsSmallestAdequateSize() async throws {
// Very small load: 1m, 0.5A, 12V
// minCS = (2×0.5×1×0.017)/(12×0.05) = 0.0283mm² rounds up to 0.75mm² (smallest standard)
let cs = ElectricalCalculations.recommendedCrossSection(
length: 1, current: 0.5, voltage: 12, unitSystem: .metric
)
#expect(cs == 0.75)
}
@Test func metricCrossSectionForHighCurrent() async throws {
// 15m, 80A, 12V
// minCS = (2×80×15×0.017)/(12×0.05) = 40.8/0.6 = 68mm² rounds up to 70mm²
let cs = ElectricalCalculations.recommendedCrossSection(
length: 15, current: 80, voltage: 12, unitSystem: .metric
)
#expect(cs == 70.0)
}
@Test func metricCrossSectionFor24VSystem() async throws {
// 10m, 20A, 24V
// minCS = (2×20×10×0.017)/(24×0.05) = 6.8/1.2 = 5.667mm² rounds up to 6.0mm²
let cs = ElectricalCalculations.recommendedCrossSection(
length: 10, current: 20, voltage: 24, unitSystem: .metric
)
#expect(cs == 6.0)
}
@Test func metricCrossSectionFor48VSystem() async throws {
// 10m, 20A, 48V
// minCS = (2×20×10×0.017)/(48×0.05) = 6.8/2.4 = 2.833mm² rounds up to 4.0mm²
let cs = ElectricalCalculations.recommendedCrossSection(
length: 10, current: 20, voltage: 48, unitSystem: .metric
)
#expect(cs == 4.0)
}
// MARK: - Imperial AWG Selection (including 1/0 through 4/0)
@Test func imperialAWGLargeGauges() async throws {
// Very high current should select large AWG sizes (represented as negative ints)
// 10m, 100A, 12V minCS = (2×100×10×0.017)/(12×0.05) = 34/0.6 = 56.67mm²
// AWG: first awgCS 56.67 67.4 = 2/0 (represented as -2)
let awg = ElectricalCalculations.recommendedCrossSection(
length: 10, current: 100, voltage: 12, unitSystem: .imperial
)
#expect(awg == -2.0)
// Verify voltage drop uses the correct cross-section (67.4mm² for 2/0)
let drop = ElectricalCalculations.voltageDrop(
length: 10, current: 100, voltage: 12,
unitSystem: .imperial, crossSection: awg
)
// (2×100×10×0.017)/67.4 = 34/67.4 = 0.5045V
#expect(abs(drop - 0.5045) < 0.01)
}
@Test func imperialAWG4over0ForExtremeCurrent() async throws {
// 5m, 200A, 12V minCS = (2×200×5×0.017)/(12×0.05) = 34/0.6 = 56.67mm²
// Wait: (2×200×5×0.017)/0.6 = 34/0.6 = 56.67 67.4 (2/0)
// 10m, 200A, 12V minCS = (2×200×10×0.017)/(12×0.05) = 68/0.6 = 113.33mm²
// AWG: first awgCS 113.33 none! returns last AWG = -4 (4/0, 107mm²)
let awg = ElectricalCalculations.recommendedCrossSection(
length: 10, current: 200, voltage: 12, unitSystem: .imperial
)
#expect(awg == -4.0)
}
@Test func formatAWGDisplaysCorrectNotation() async throws {
#expect(ElectricalCalculations.formatAWG(14) == "14")
#expect(ElectricalCalculations.formatAWG(10) == "10")
#expect(ElectricalCalculations.formatAWG(1) == "1")
#expect(ElectricalCalculations.formatAWG(-1) == "1/0")
#expect(ElectricalCalculations.formatAWG(-2) == "2/0")
#expect(ElectricalCalculations.formatAWG(-3) == "3/0")
#expect(ElectricalCalculations.formatAWG(-4) == "4/0")
}
@Test func imperialAWGForBoatLoad() async throws {
// Typical boat scenario: 5m (16ft), 10A, 12V
// minCS = (2×10×5×0.017)/(12×0.05) = 1.7/0.6 = 2.833mm²
// AWG: first >= 2.833 3.31mm² = 12 AWG
let awg = ElectricalCalculations.recommendedCrossSection(
length: 5, current: 10, voltage: 12, unitSystem: .imperial
)
#expect(awg == 12.0)
}
@Test func imperialAWGForHighCurrentLoad() async throws {
// 3m, 50A, 12V
// minCS = (2×50×3×0.017)/(12×0.05) = 5.1/0.6 = 8.5mm²
// AWG: first >= 8.5 13.3mm² = 6 AWG
let awg = ElectricalCalculations.recommendedCrossSection(
length: 3, current: 50, voltage: 12, unitSystem: .imperial
)
#expect(awg == 6.0)
}
// MARK: - Voltage Drop with Explicit Cross-Section
@Test func voltageDropWithExplicitCrossSection_metric() async throws {
// 5m, 10A, 12V, 2.5mm²
// V_drop = (2×10×5×0.017)/2.5 = 1.7/2.5 = 0.68V
let drop = ElectricalCalculations.voltageDrop(
length: 5, current: 10, voltage: 12,
unitSystem: .metric, crossSection: 2.5
)
#expect(abs(drop - 0.68) < 0.001)
let pct = ElectricalCalculations.voltageDropPercentage(
length: 5, current: 10, voltage: 12,
unitSystem: .metric, crossSection: 2.5
)
// 0.68/12 × 100 = 5.667%
#expect(abs(pct - 5.667) < 0.01)
}
@Test func voltageDropWithExplicitCrossSection_imperial() async throws {
// 3m, 15A, 12V, AWG 10 (= 5.26mm²)
// V_drop = (2×15×3×0.017)/5.26 = 1.53/5.26 = 0.2909V
let drop = ElectricalCalculations.voltageDrop(
length: 3, current: 15, voltage: 12,
unitSystem: .imperial, crossSection: 10 // AWG 10
)
#expect(abs(drop - 0.2909) < 0.01)
}
// MARK: - Power Loss Verification
@Test func powerLossEqualsCurrentTimesVoltageDrop() async throws {
let length = 8.0
let current = 12.0
let voltage = 24.0
let drop = ElectricalCalculations.voltageDrop(
length: length, current: current, voltage: voltage, unitSystem: .metric
)
let loss = ElectricalCalculations.powerLoss(
length: length, current: current, voltage: voltage, unitSystem: .metric
)
// P_loss = I × V_drop
#expect(abs(loss - current * drop) < 0.001)
}
// MARK: - Edge Cases
@Test func zeroLengthProducesZeroDrop() async throws {
let drop = ElectricalCalculations.voltageDrop(
length: 0, current: 10, voltage: 12, unitSystem: .metric
)
#expect(drop == 0)
let loss = ElectricalCalculations.powerLoss(
length: 0, current: 10, voltage: 12, unitSystem: .metric
)
#expect(loss == 0)
}
@Test func zeroCurrentProducesZeroDrop() async throws {
let drop = ElectricalCalculations.voltageDrop(
length: 10, current: 0, voltage: 12, unitSystem: .metric
)
#expect(drop == 0)
}
@Test func zeroVoltageReturnsZeroPercentage() async throws {
let pct = ElectricalCalculations.voltageDropPercentage(
length: 10, current: 5, voltage: 0, unitSystem: .metric
)
#expect(pct == 0)
}
@Test func zeroVoltageReturnsZeroCrossSection() async throws {
// With 0V, maxVoltageDrop = 0, guardAgainstZero returns 0 smallest standard
let cs = ElectricalCalculations.recommendedCrossSection(
length: 10, current: 5, voltage: 0, unitSystem: .metric
)
#expect(cs == 0.75)
}
// MARK: - Metric and Imperial Consistency
@Test func metricAndImperialGiveSamePhysicalVoltageDrop() async throws {
// Same physical setup: 10m cable, 15A, 12V
// Metric: recommended cross-section in mm²
// Imperial: recommended cross-section in AWG
let lengthMeters = 10.0
let metricCS = ElectricalCalculations.recommendedCrossSection(
length: lengthMeters, current: 15, voltage: 12, unitSystem: .metric
)
let imperialAWG = ElectricalCalculations.recommendedCrossSection(
length: lengthMeters, current: 15, voltage: 12, unitSystem: .imperial
)
let metricDrop = ElectricalCalculations.voltageDrop(
length: lengthMeters, current: 15, voltage: 12,
unitSystem: .metric, crossSection: metricCS
)
let imperialDrop = ElectricalCalculations.voltageDrop(
length: lengthMeters, current: 15, voltage: 12,
unitSystem: .imperial, crossSection: imperialAWG
)
// Both should be within 5% voltage drop constraint
#expect(metricDrop / 12 <= 0.05)
#expect(imperialDrop / 12 <= 0.05)
// Both drops should be positive
#expect(metricDrop > 0)
#expect(imperialDrop > 0)
}
// MARK: - Unit Convention: Length Always in Meters
@Test func lengthParameterIsAlwaysMeters() async throws {
// Passing the same meter value with both unit systems should give
// the same underlying calculation, only differing in output format.
let lengthMeters = 7.62 // = 25 feet
let metricMinCS = ElectricalCalculations.recommendedCrossSection(
length: lengthMeters, current: 15, voltage: 120, unitSystem: .metric
)
let imperialAWG = ElectricalCalculations.recommendedCrossSection(
length: lengthMeters, current: 15, voltage: 120, unitSystem: .imperial
)
// Minimum raw cross-section: (2×15×7.62×0.017)/(120×0.05) = 0.648mm²
// Metric: rounds to 0.75mm² (smallest standard 0.648)
#expect(metricMinCS == 0.75)
// Imperial: AWG 18 (0.823mm² 0.648)
#expect(imperialAWG == 18.0)
}
// MARK: - Voltage Drop Constraint
@Test func recommendedCrossSectionKeepsDropBelow5Percent() async throws {
// Test several scenarios to verify the 5% constraint
let scenarios: [(length: Double, current: Double, voltage: Double)] = [
(1, 1, 12), // minimal
(5, 10, 12), // moderate boat load
(10, 20, 24), // 24V system
(15, 50, 12), // high current
(3, 100, 48), // very high current, 48V
(20, 5, 12), // long run, low current
]
for s in scenarios {
let cs = ElectricalCalculations.recommendedCrossSection(
length: s.length, current: s.current, voltage: s.voltage, unitSystem: .metric
)
let dropPct = ElectricalCalculations.voltageDropPercentage(
length: s.length, current: s.current, voltage: s.voltage,
unitSystem: .metric, crossSection: cs
)
#expect(dropPct <= 5.0, "Drop \(dropPct)% exceeds 5% for \(s.length)m, \(s.current)A, \(s.voltage)V with \(cs)mm²")
}
}
// MARK: - Formula Cross-Check: V_drop = I²R path
@Test func voltageDropMatchesOhmsLaw() async throws {
let length = 6.0
let current = 8.0
let crossSection = 4.0 // mm²
let resistivity = 0.017
// R = (2 × L × ρ) / A = (2 × 6 × 0.017) / 4 = 0.051 Ω
let resistance = (2 * length * resistivity) / crossSection
// V = I × R = 8 × 0.051 = 0.408V
let expectedDrop = current * resistance
// P = I² × R = 64 × 0.051 = 3.264W
let expectedPowerLoss = current * current * resistance
let actualDrop = ElectricalCalculations.voltageDrop(
length: length, current: current, voltage: 12,
unitSystem: .metric, crossSection: crossSection
)
let actualLoss = ElectricalCalculations.powerLoss(
length: length, current: current, voltage: 12,
unitSystem: .metric, crossSection: crossSection
)
#expect(abs(actualDrop - expectedDrop) < 0.001)
#expect(abs(actualLoss - expectedPowerLoss) < 0.001)
} }
} }