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:
110
CLAUDE.md
110
CLAUDE.md
@@ -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` (0–44, 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):
|
||||||
|
- 0–4: Overview tab
|
||||||
|
- 5–9: Components tab
|
||||||
|
- 10–14: Batteries tab
|
||||||
|
- 15–19: Chargers tab
|
||||||
|
- 20–24: Systems list
|
||||||
|
- 25–29: Parts Library
|
||||||
|
- 30–34: Load editor (CalculatorView)
|
||||||
|
- 35–39: Battery editor
|
||||||
|
- 40–44: 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`.
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,22 +1175,19 @@ 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",
|
return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit),
|
||||||
comment: "Title format for the cable length slider"
|
|
||||||
)
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
range: lengthSliderRange,
|
range: lengthSliderRange,
|
||||||
unit: unitSettings.unitSystem.lengthUnit,
|
unit: unitSettings.unitSystem.lengthUnit,
|
||||||
tapAction: beginLengthEditing,
|
tapAction: beginLengthEditing,
|
||||||
snapValues: editingValue == .length ? nil : lengthSnapValues)
|
snapValues: editingValue == .length ? nil : lengthSnapValues)
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -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,9 +534,19 @@ 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 {
|
||||||
|
|||||||
@@ -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 {
|
let index = standardAWG.firstIndex(of: Int(awg)) ?? -1
|
||||||
case 00: return 67.4
|
if index >= 0 && index < awgCrossSections.count {
|
||||||
case 000: return 85.0
|
return awgCrossSections[index]
|
||||||
case 0000: return 107.0
|
}
|
||||||
default:
|
return 0.75
|
||||||
let index = standardAWG.firstIndex(of: Int(awg)) ?? -1
|
}
|
||||||
if index >= 0 && index < awgCrossSections.count {
|
|
||||||
return awgCrossSections[index]
|
/// Formats an AWG value for display: positive values as-is, negative as "X/0" notation.
|
||||||
}
|
static func formatAWG(_ awg: Double) -> String {
|
||||||
return 0.75
|
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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 l’autonomie.";
|
"overview.runtime.unavailable" = "Ajoutez la capacité de la batterie et la puissance consommée pour estimer l’autonomie.";
|
||||||
"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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user