From ea3b60d75c46e3963c29e5d1c2e680c32ef9b30a Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Fri, 27 Mar 2026 10:37:53 +0100 Subject: [PATCH] Fix AWG notation, add alternator type, migrate to String(localized:) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- CLAUDE.md | 110 +++++- Cable/Base.lproj/Localizable.strings | 1 + Cable/Batteries/BatteriesView.swift | 81 ++--- Cable/Batteries/BatteryEditorView.swift | 320 ++++++---------- Cable/Chargers/ChargerEditorView.swift | 104 ++---- Cable/Chargers/SavedCharger.swift | 3 + Cable/Loads/CableCalculator.swift | 7 +- Cable/Loads/CalculatorView.swift | 104 +++--- Cable/Loads/ComponentLibraryView.swift | 18 +- Cable/Loads/ElectricalCalculations.swift | 42 ++- Cable/Loads/LoadConfigurationStatus.swift | 40 +- Cable/Loads/LoadsView.swift | 94 ++--- .../Overview/SystemOverviewPDFExporter.swift | 5 +- Cable/Overview/SystemOverviewView.swift | 311 +++------------- .../SystemBillOfMaterialsPDFExporter.swift | 30 +- Cable/Systems/SystemBillOfMaterialsView.swift | 163 +++------ .../Systems/SystemComponentsPersistence.swift | 16 +- Cable/Systems/SystemsView.swift | 13 +- Cable/UITestSampleData.swift | 3 +- Cable/de.lproj/Localizable.strings | 3 +- Cable/es.lproj/Localizable.strings | 5 +- Cable/fr.lproj/Localizable.strings | 5 +- Cable/nl.lproj/Localizable.strings | 7 +- CableTests/CableTests.swift | 342 +++++++++++++++++- 24 files changed, 899 insertions(+), 928 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e0cbd0f..6bbab91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ No external dependencies beyond the Xcode toolchain. ### 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 - `SavedBattery` — battery banks with chemistry-specific capacity rules - `SavedCharger` — charging equipment specs @@ -29,8 +29,8 @@ All are `@Model` classes persisted via SwiftData. The container is configured in ### 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. -- **CableCalculator** (`Loads/CableCalculator.swift`): ObservableObject wrapper that bridges the calculation engine to SwiftUI views. -- **App-wide state**: `UnitSystemSettings` (metric/imperial, persisted to UserDefaults) and `StoreKitManager` (subscription status) are injected as `@EnvironmentObject`. +- **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) is injected as `@EnvironmentObject`. ### Navigation Flow @@ -38,7 +38,59 @@ All are `@Model` classes persisted via SwiftData. The container is configured in ### 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 @@ -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. - **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`. diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index 82b817a..540cbb4 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -377,6 +377,7 @@ "charger.source.solar" = "Solar"; "charger.source.wind" = "Wind"; "charger.source.generator" = "Generator"; +"charger.source.alternator" = "Alternator"; // MARK: - Share Menu "overview.share.diagram" = "Wiring Diagram"; diff --git a/Cable/Batteries/BatteriesView.swift b/Cable/Batteries/BatteriesView.swift index a67aa0d..64223d4 100644 --- a/Cable/Batteries/BatteriesView.swift +++ b/Cable/Batteries/BatteriesView.swift @@ -166,11 +166,9 @@ struct BatteriesView: View { message: Text(detail.message), dismissButton: .default( Text( - NSLocalizedString( - "battery.bank.status.dismiss", - bundle: .main, - value: "Got it", - comment: "Dismiss button title for battery bank status alert" + String( + localized: "battery.bank.status.dismiss", + defaultValue: "Got it" ) ) ) @@ -516,12 +514,9 @@ struct BatteriesView: View { } private func emptySubtitle(for systemName: String) -> String { - let format = NSLocalizedString( - "battery.bank.empty.subtitle", - tableName: nil, - bundle: .main, - value: "Tap the plus button to configure a battery for %@.", - comment: "Subtitle shown when no batteries are configured" + let format = String( + localized: "battery.bank.empty.subtitle", + defaultValue: "Tap the plus button to configure a battery for %@." ) return String(format: format, systemName) } @@ -530,75 +525,59 @@ struct BatteriesView: View { switch status { case let .voltage(target, mismatchedCount): let countText = mismatchedCount == 1 - ? NSLocalizedString( - "battery.bank.status.single.battery", - bundle: .main, - value: "One battery", - comment: "Singular form describing mismatched battery count" + ? String( + localized: "battery.bank.status.single.battery", + defaultValue: "One battery" ) : String( - format: NSLocalizedString( - "battery.bank.status.multiple.batteries", - bundle: .main, - value: "%d batteries", - comment: "Plural form describing mismatched battery count" + format: String( + localized: "battery.bank.status.multiple.batteries", + defaultValue: "%d batteries" ), mismatchedCount ) let expected = formattedValue(target, unit: "V") let message = String( - format: NSLocalizedString( - "battery.bank.status.voltage.message", - bundle: .main, - value: "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters.", - comment: "Explanation for voltage mismatch in battery bank" + format: String( + localized: "battery.bank.status.voltage.message", + defaultValue: "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters." ), countText, expected ) return ( - NSLocalizedString( - "battery.bank.status.voltage.title", - bundle: .main, - value: "Voltage mismatch", - comment: "Title for voltage mismatch alert" + String( + localized: "battery.bank.status.voltage.title", + defaultValue: "Voltage mismatch" ), message ) case let .capacity(target, mismatchedCount): let countText = mismatchedCount == 1 - ? NSLocalizedString( - "battery.bank.status.single.battery", - bundle: .main, - value: "One battery", - comment: "Singular form describing mismatched battery count" + ? String( + localized: "battery.bank.status.single.battery", + defaultValue: "One battery" ) : String( - format: NSLocalizedString( - "battery.bank.status.multiple.batteries", - bundle: .main, - value: "%d batteries", - comment: "Plural form describing mismatched battery count" + format: String( + localized: "battery.bank.status.multiple.batteries", + defaultValue: "%d batteries" ), mismatchedCount ) let expected = formattedValue(target, unit: "Ah") let message = String( - format: NSLocalizedString( - "battery.bank.status.capacity.message", - bundle: .main, - value: "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear.", - comment: "Explanation for capacity mismatch in battery bank" + format: String( + localized: "battery.bank.status.capacity.message", + defaultValue: "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear." ), countText, expected ) return ( - NSLocalizedString( - "battery.bank.status.capacity.title", - bundle: .main, - value: "Capacity mismatch", - comment: "Title for capacity mismatch alert" + String( + localized: "battery.bank.status.capacity.title", + defaultValue: "Capacity mismatch" ), message ) diff --git a/Cable/Batteries/BatteryEditorView.swift b/Cable/Batteries/BatteryEditorView.swift index 96c1fd6..a08d1ec 100644 --- a/Cable/Batteries/BatteryEditorView.swift +++ b/Cable/Batteries/BatteryEditorView.swift @@ -108,245 +108,191 @@ struct BatteryEditorView: View { } private var alertCancelTitle: String { - NSLocalizedString( - "battery.editor.alert.cancel", - bundle: .main, - value: "Cancel", - comment: "Cancel button title for edit alerts" + String( + localized: "battery.editor.alert.cancel", + defaultValue: "Cancel" ) } private var alertSaveTitle: String { - NSLocalizedString( - "battery.editor.alert.save", - bundle: .main, - value: "Save", - comment: "Save button title for edit alerts" + String( + localized: "battery.editor.alert.save", + defaultValue: "Save" ) } private var voltageAlertTitle: String { - NSLocalizedString( - "battery.editor.alert.voltage.title", - bundle: .main, - value: "Edit Nominal Voltage", - comment: "Title for the voltage edit alert" + String( + localized: "battery.editor.alert.voltage.title", + defaultValue: "Edit Nominal Voltage" ) } private var voltageAlertPlaceholder: String { - NSLocalizedString( - "battery.editor.alert.voltage.placeholder", - bundle: .main, - value: "Voltage", - comment: "Placeholder for voltage text field" + String( + localized: "battery.editor.alert.voltage.placeholder", + defaultValue: "Voltage" ) } private var voltageAlertMessage: String { - NSLocalizedString( - "battery.editor.alert.voltage.message", - bundle: .main, - value: "Enter voltage in volts (V)", - comment: "Message for the voltage edit alert" + String( + localized: "battery.editor.alert.voltage.message", + defaultValue: "Enter voltage in volts (V)" ) } private var capacityAlertTitle: String { - NSLocalizedString( - "battery.editor.alert.capacity.title", - bundle: .main, - value: "Edit Capacity", - comment: "Title for the capacity edit alert" + String( + localized: "battery.editor.alert.capacity.title", + defaultValue: "Edit Capacity" ) } private var capacityAlertPlaceholder: String { - NSLocalizedString( - "battery.editor.alert.capacity.placeholder", - bundle: .main, - value: "Capacity", - comment: "Placeholder for capacity text field" + String( + localized: "battery.editor.alert.capacity.placeholder", + defaultValue: "Capacity" ) } private var capacityAlertMessage: String { - NSLocalizedString( - "battery.editor.alert.capacity.message", - bundle: .main, - value: "Enter capacity in amp-hours (Ah)", - comment: "Message for the capacity edit alert" + String( + localized: "battery.editor.alert.capacity.message", + defaultValue: "Enter capacity in amp-hours (Ah)" ) } private var usableCapacityAlertTitle: String { - NSLocalizedString( - "battery.editor.alert.usable_capacity.title", - bundle: .main, - value: "Edit Usable Capacity", - comment: "Title for the usable capacity edit alert" + String( + localized: "battery.editor.alert.usable_capacity.title", + defaultValue: "Edit Usable Capacity" ) } private var usableCapacityAlertPlaceholder: String { - NSLocalizedString( - "battery.editor.alert.usable_capacity.placeholder", - bundle: .main, - value: "Usable Capacity (%)", - comment: "Placeholder for the usable capacity text field" + String( + localized: "battery.editor.alert.usable_capacity.placeholder", + defaultValue: "Usable Capacity (%)" ) } private var usableCapacityAlertMessage: String { - NSLocalizedString( - "battery.editor.alert.usable_capacity.message", - bundle: .main, - value: "Enter usable capacity as a percentage (%)", - comment: "Message for the usable capacity edit alert" + String( + localized: "battery.editor.alert.usable_capacity.message", + defaultValue: "Enter usable capacity percentage (%)" ) } private var chargeVoltageAlertTitle: String { - NSLocalizedString( - "battery.editor.alert.charge_voltage.title", - bundle: .main, - value: "Edit Charge Voltage", - comment: "Title for the charge voltage edit alert" + String( + localized: "battery.editor.alert.charge_voltage.title", + defaultValue: "Edit Charge Voltage" ) } private var chargeVoltageAlertPlaceholder: String { - NSLocalizedString( - "battery.editor.alert.charge_voltage.placeholder", - bundle: .main, - value: "Charge Voltage", - comment: "Placeholder for the charge voltage text field" + String( + localized: "battery.editor.alert.charge_voltage.placeholder", + defaultValue: "Charge Voltage" ) } private var chargeVoltageAlertMessage: String { - NSLocalizedString( - "battery.editor.alert.charge_voltage.message", - bundle: .main, - value: "Enter charge voltage in volts (V)", - comment: "Message for the charge voltage edit alert" + String( + localized: "battery.editor.alert.charge_voltage.message", + defaultValue: "Enter charge voltage in volts (V)" ) } private var cutOffVoltageAlertTitle: String { - NSLocalizedString( - "battery.editor.alert.cutoff_voltage.title", - bundle: .main, - value: "Edit Cut-off Voltage", - comment: "Title for the cut-off voltage edit alert" + String( + localized: "battery.editor.alert.cutoff_voltage.title", + defaultValue: "Edit Cut-off Voltage" ) } private var cutOffVoltageAlertPlaceholder: String { - NSLocalizedString( - "battery.editor.alert.cutoff_voltage.placeholder", - bundle: .main, - value: "Cut-off Voltage", - comment: "Placeholder for the cut-off voltage text field" + String( + localized: "battery.editor.alert.cutoff_voltage.placeholder", + defaultValue: "Cut-off Voltage" ) } private var cutOffVoltageAlertMessage: String { - NSLocalizedString( - "battery.editor.alert.cutoff_voltage.message", - bundle: .main, - value: "Enter cut-off voltage in volts (V)", - comment: "Message for the cut-off voltage edit alert" + String( + localized: "battery.editor.alert.cutoff_voltage.message", + defaultValue: "Enter cut-off voltage in volts (V)" ) } private var chargeVoltageTitle: String { - NSLocalizedString( - "battery.editor.slider.charge_voltage", - bundle: .main, - value: "Charge Voltage", - comment: "Title for the charge voltage slider" + String( + localized: "battery.editor.slider.charge_voltage", + defaultValue: "Charge Voltage" ) } private var cutOffVoltageTitle: String { - NSLocalizedString( - "battery.editor.slider.cutoff_voltage", - bundle: .main, - value: "Cut-off Voltage", - comment: "Title for the cut-off voltage slider" + String( + localized: "battery.editor.slider.cutoff_voltage", + defaultValue: "Cut-off Voltage" ) } private var temperatureRangeTitle: String { - NSLocalizedString( - "battery.editor.slider.temperature_range", - bundle: .main, - value: "Temperature Range", - comment: "Title for the temperature range editor" + String( + localized: "battery.editor.slider.temperature_range", + defaultValue: "Temperature Range" ) } private var chargeVoltageHelperText: String { - NSLocalizedString( - "battery.editor.advanced.charge_voltage.helper", - bundle: .main, - value: "Set the maximum recommended charging voltage.", - comment: "Helper text explaining charge voltage" + String( + localized: "battery.editor.advanced.charge_voltage.helper", + defaultValue: "Set the maximum recommended charging voltage." ) } private var cutOffVoltageHelperText: String { - NSLocalizedString( - "battery.editor.advanced.cutoff_voltage.helper", - bundle: .main, - value: "Set the minimum safe discharge voltage.", - comment: "Helper text explaining cut-off voltage" + String( + localized: "battery.editor.advanced.cutoff_voltage.helper", + defaultValue: "Set the minimum safe discharge voltage." ) } private var temperatureRangeHelperText: String { - NSLocalizedString( - "battery.editor.advanced.temperature_range.helper", - bundle: .main, - value: "Define the recommended operating temperature range.", - comment: "Helper text explaining temperature range" + String( + localized: "battery.editor.advanced.temperature_range.helper", + defaultValue: "Define the recommended operating temperature range." ) } private var minimumTemperatureLabel: String { - NSLocalizedString( - "battery.editor.slider.temperature_range.min", - bundle: .main, - value: "Minimum", - comment: "Label for minimum temperature control" + String( + localized: "battery.editor.slider.temperature_range.min", + defaultValue: "Minimum" ) } private var maximumTemperatureLabel: String { - NSLocalizedString( - "battery.editor.slider.temperature_range.max", - bundle: .main, - value: "Maximum", - comment: "Label for maximum temperature control" + String( + localized: "battery.editor.slider.temperature_range.max", + defaultValue: "Maximum" ) } private var usableCapacityDefaultFooterFormat: String { - NSLocalizedString( - "battery.editor.advanced.usable_capacity.footer_default", - bundle: .main, - value: "Default value %@ based on chemistry.", - comment: "Footer text explaining the default usable capacity value" + String( + localized: "battery.editor.advanced.usable_capacity.footer_default", + defaultValue: "Defaults to %@ based on chemistry." ) } private var usableCapacityOverrideFooterFormat: String { - NSLocalizedString( - "battery.editor.advanced.usable_capacity.footer_override", - bundle: .main, - value: "Manual override active. Chemistry default remains %@.", - comment: "Footer text explaining the usable capacity override" + String( + localized: "battery.editor.advanced.usable_capacity.footer_override", + defaultValue: "Override active. Chemistry default remains %@." ) } @@ -388,29 +334,23 @@ struct BatteryEditorView: View { } private var appearanceEditorTitle: String { - NSLocalizedString( - "battery.editor.appearance.title", - bundle: .main, - value: "Battery Appearance", - comment: "Title for the battery appearance editor" + String( + localized: "battery.editor.appearance.title", + defaultValue: "Battery Appearance" ) } private var appearanceEditorSubtitle: String { - NSLocalizedString( - "battery.editor.appearance.subtitle", - bundle: .main, - value: "Customize how this battery shows up", - comment: "Subtitle shown in the battery appearance editor preview" + String( + localized: "battery.editor.appearance.subtitle", + defaultValue: "Customize how this battery shows up" ) } private var appearanceAccessibilityLabel: String { - NSLocalizedString( - "battery.editor.appearance.accessibility", - bundle: .main, - value: "Edit battery appearance", - comment: "Accessibility label for the battery appearance editor button" + String( + localized: "battery.editor.appearance.accessibility", + defaultValue: "Edit battery appearance" ) } @@ -527,11 +467,9 @@ struct BatteryEditorView: View { ) } .alert( - NSLocalizedString( - "battery.editor.alert.minimum_temperature.title", - bundle: .main, - value: "Edit Minimum Temperature", - comment: "Title for the minimum temperature edit alert" + String( + localized: "battery.editor.alert.minimum_temperature.title", + defaultValue: "Edit Minimum Temperature" ), isPresented: Binding( get: { temperatureEditingField == .minimumTemperature }, @@ -544,11 +482,9 @@ struct BatteryEditorView: View { ) ) { TextField( - NSLocalizedString( - "battery.editor.alert.minimum_temperature.placeholder", - bundle: .main, - value: "Minimum Temperature (°C)", - comment: "Placeholder for the minimum temperature text field" + String( + localized: "battery.editor.alert.minimum_temperature.placeholder", + defaultValue: "Minimum Temperature (\u{00B0}C)" ), text: $minimumTemperatureInput ) @@ -564,11 +500,9 @@ struct BatteryEditorView: View { } Button( - NSLocalizedString( - "battery.editor.alert.cancel", - bundle: .main, - value: "Cancel", - comment: "Cancel button title for edit alerts" + String( + localized: "battery.editor.alert.cancel", + defaultValue: "Cancel" ), role: .cancel ) { @@ -577,11 +511,9 @@ struct BatteryEditorView: View { } Button( - NSLocalizedString( - "battery.editor.alert.save", - bundle: .main, - value: "Save", - comment: "Save button title for edit alerts" + String( + localized: "battery.editor.alert.save", + defaultValue: "Save" ) ) { if let parsed = parseInput(minimumTemperatureInput) { @@ -592,20 +524,16 @@ struct BatteryEditorView: View { } } message: { Text( - NSLocalizedString( - "battery.editor.alert.minimum_temperature.message", - bundle: .main, - value: "Enter minimum temperature in degrees Celsius (°C)", - comment: "Message for the minimum temperature edit alert" + String( + localized: "battery.editor.alert.minimum_temperature.message", + defaultValue: "Enter minimum temperature in degrees Celsius (\u{00B0}C)" ) ) } .alert( - NSLocalizedString( - "battery.editor.alert.maximum_temperature.title", - bundle: .main, - value: "Edit Maximum Temperature", - comment: "Title for the maximum temperature edit alert" + String( + localized: "battery.editor.alert.maximum_temperature.title", + defaultValue: "Edit Maximum Temperature" ), isPresented: Binding( get: { temperatureEditingField == .maximumTemperature }, @@ -618,11 +546,9 @@ struct BatteryEditorView: View { ) ) { TextField( - NSLocalizedString( - "battery.editor.alert.maximum_temperature.placeholder", - bundle: .main, - value: "Maximum Temperature (°C)", - comment: "Placeholder for the maximum temperature text field" + String( + localized: "battery.editor.alert.maximum_temperature.placeholder", + defaultValue: "Maximum Temperature (\u{00B0}C)" ), text: $maximumTemperatureInput ) @@ -638,11 +564,9 @@ struct BatteryEditorView: View { } Button( - NSLocalizedString( - "battery.editor.alert.cancel", - bundle: .main, - value: "Cancel", - comment: "Cancel button title for edit alerts" + String( + localized: "battery.editor.alert.cancel", + defaultValue: "Cancel" ), role: .cancel ) { @@ -651,11 +575,9 @@ struct BatteryEditorView: View { } Button( - NSLocalizedString( - "battery.editor.alert.save", - bundle: .main, - value: "Save", - comment: "Save button title for edit alerts" + String( + localized: "battery.editor.alert.save", + defaultValue: "Save" ) ) { if let parsed = parseInput(maximumTemperatureInput) { @@ -666,11 +588,9 @@ struct BatteryEditorView: View { } } message: { Text( - NSLocalizedString( - "battery.editor.alert.maximum_temperature.message", - bundle: .main, - value: "Enter maximum temperature in degrees Celsius (°C)", - comment: "Message for the maximum temperature edit alert" + String( + localized: "battery.editor.alert.maximum_temperature.message", + defaultValue: "Enter maximum temperature in degrees Celsius (\u{00B0}C)" ) ) } diff --git a/Cable/Chargers/ChargerEditorView.swift b/Cable/Chargers/ChargerEditorView.swift index cbec7c0..ad5722f 100644 --- a/Cable/Chargers/ChargerEditorView.swift +++ b/Cable/Chargers/ChargerEditorView.swift @@ -120,29 +120,23 @@ struct ChargerEditorView: View { } private var appearanceEditorTitle: String { - NSLocalizedString( - "charger.editor.appearance.title", - bundle: .main, - value: "Charger Appearance", - comment: "Title for the charger appearance editor" + String( + localized: "charger.editor.appearance.title", + defaultValue: "Charger Appearance" ) } private var appearanceEditorSubtitle: String { - NSLocalizedString( - "charger.editor.appearance.subtitle", - bundle: .main, - value: "Customize how this charger shows up", - comment: "Subtitle shown in the charger appearance editor preview" + String( + localized: "charger.editor.appearance.subtitle", + defaultValue: "Customize how this charger shows up" ) } private var appearanceAccessibilityLabel: String { - NSLocalizedString( - "charger.editor.appearance.accessibility", - bundle: .main, - value: "Edit charger appearance", - comment: "Accessibility label for the charger appearance editor button" + String( + localized: "charger.editor.appearance.accessibility", + defaultValue: "Edit charger appearance" ) } @@ -195,92 +189,72 @@ struct ChargerEditorView: View { } private var inputVoltageAlertTitle: String { - NSLocalizedString( - "charger.editor.alert.input_voltage.title", - bundle: .main, - value: "Edit Input Voltage", - comment: "Title for the input voltage edit alert" + String( + localized: "charger.editor.alert.input_voltage.title", + defaultValue: "Edit Input Voltage" ) } private var outputVoltageAlertTitle: String { - NSLocalizedString( - "charger.editor.alert.output_voltage.title", - bundle: .main, - value: "Edit Output Voltage", - comment: "Title for the output voltage edit alert" + String( + localized: "charger.editor.alert.output_voltage.title", + defaultValue: "Edit Output Voltage" ) } private var currentAlertTitle: String { - NSLocalizedString( - "charger.editor.alert.current.title", - bundle: .main, - value: "Edit Charge Current", - comment: "Title for the charging current edit alert" + String( + localized: "charger.editor.alert.current.title", + defaultValue: "Edit Charge Current" ) } private var powerAlertTitle: String { - NSLocalizedString( - "charger.editor.alert.power.title", - bundle: .main, - value: "Edit Charge Power", - comment: "Title for the power edit alert" + String( + localized: "charger.editor.alert.power.title", + defaultValue: "Edit Charge Power" ) } private var alertMessageVoltage: String { - NSLocalizedString( - "charger.editor.alert.voltage.message", - bundle: .main, - value: "Enter voltage in volts (V)", - comment: "Message for voltage edit alerts" + String( + localized: "charger.editor.alert.voltage.message", + defaultValue: "Enter voltage in volts (V)" ) } private var alertMessagePower: String { - NSLocalizedString( - "charger.editor.alert.power.message", - bundle: .main, - value: "Enter power in watts (W)", - comment: "Message for the power edit alert" + String( + localized: "charger.editor.alert.power.message", + defaultValue: "Enter power in watts (W)" ) } private var alertMessageCurrent: String { - NSLocalizedString( - "charger.editor.alert.current.message", - bundle: .main, - value: "Enter current in amps (A)", - comment: "Message for the current edit alert" + String( + localized: "charger.editor.alert.current.message", + defaultValue: "Enter current in amps (A)" ) } private var alertCancelTitle: String { - NSLocalizedString( - "charger.editor.alert.cancel", - bundle: .main, - value: "Cancel", - comment: "Title for cancel buttons in edit alerts" + String( + localized: "charger.editor.alert.cancel", + defaultValue: "Cancel" ) } private var alertSaveTitle: String { - NSLocalizedString( - "charger.editor.alert.save", - bundle: .main, - value: "Save", - comment: "Title for save buttons in edit alerts" + String( + localized: "charger.editor.alert.save", + defaultValue: "Save" ) } private var powerAlertPlaceholder: String { - NSLocalizedString( - "charger.editor.alert.power.placeholder", - bundle: .main, - value: "Power", - comment: "Placeholder for the power edit alert" + String( + localized: "charger.editor.alert.power.placeholder", + defaultValue: "Power" ) } diff --git a/Cable/Chargers/SavedCharger.swift b/Cable/Chargers/SavedCharger.swift index 39afc59..ce18bd8 100644 --- a/Cable/Chargers/SavedCharger.swift +++ b/Cable/Chargers/SavedCharger.swift @@ -25,6 +25,7 @@ final class SavedCharger { case solar = "solar" case wind = "wind" case generator = "generator" + case alternator = "alternator" var id: Self { self } @@ -34,6 +35,7 @@ final class SavedCharger { case .solar: return String(localized: "charger.source.solar", defaultValue: "Solar") case .wind: return String(localized: "charger.source.wind", defaultValue: "Wind") 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 .wind: return "wind" case .generator: return "engine.combustion.fill" + case .alternator: return "bolt.car.fill" } } } diff --git a/Cable/Loads/CableCalculator.swift b/Cable/Loads/CableCalculator.swift index b33d7bb..ef672e1 100644 --- a/Cable/Loads/CableCalculator.swift +++ b/Cable/Loads/CableCalculator.swift @@ -73,9 +73,14 @@ class CableCalculator: ObservableObject { ) } - var recommendedFuse: Int { + var recommendedFuse: Double { ElectricalCalculations.recommendedFuse(forCurrent: current) } + + var recommendedFuseFormatted: String { + let fuse = recommendedFuse + return fuse == fuse.rounded() ? String(format: "%.0f", fuse) : String(format: "%.1f", fuse) + } } @Model diff --git a/Cable/Loads/CalculatorView.swift b/Cable/Loads/CalculatorView.swift index 7d0f0ca..1aaec9a 100644 --- a/Cable/Loads/CalculatorView.swift +++ b/Cable/Loads/CalculatorView.swift @@ -103,12 +103,12 @@ struct CalculatorView: View { .keyboardType(.decimalPad) .onAppear { if lengthInput.isEmpty { - lengthInput = formattedValue(calculator.length) + lengthInput = formattedValue(displayLength) } } .onChange(of: lengthInput) { _, newValue in guard editingValue == .length, let parsed = parseInput(newValue) else { return } - calculator.length = roundToTenth(parsed) + calculator.length = metersFromDisplayLength(roundToTenth(parsed)) } Button("Cancel", role: .cancel) { editingValue = nil @@ -116,7 +116,7 @@ struct CalculatorView: View { } Button("Save") { if let parsed = parseInput(lengthInput) { - calculator.length = roundToTenth(parsed) + calculator.length = metersFromDisplayLength(roundToTenth(parsed)) } editingValue = nil lengthInput = "" @@ -520,7 +520,7 @@ struct CalculatorView: View { .font(.caption2) .fontWeight(.medium) .foregroundColor(.secondary) - Text("\(calculator.recommendedFuse)A") + Text("\(calculator.recommendedFuseFormatted)A") .font(.subheadline) .fontWeight(.bold) .foregroundColor(.orange) @@ -535,11 +535,9 @@ struct CalculatorView: View { .font(.caption2) .fontWeight(.medium) .foregroundColor(.secondary) - Text(String(format: unitSettings.unitSystem == .imperial ? - "%.1fft @ %.0f AWG" : - "%.1fm @ %.1fmm²", - unitSettings.unitSystem == .imperial ? calculator.length * 3.28084 : calculator.length, - calculator.crossSection(for: unitSettings.unitSystem))) + Text(unitSettings.unitSystem == .imperial ? + String(format: "%.1fft @ %@ AWG", displayLength, ElectricalCalculations.formatAWG(calculator.crossSection(for: unitSettings.unitSystem))) : + String(format: "%.1fm @ %.1fmm²", displayLength, calculator.crossSection(for: unitSettings.unitSystem))) .font(.subheadline) .fontWeight(.bold) .foregroundColor(.blue) @@ -600,7 +598,7 @@ struct CalculatorView: View { .frame(height: 30) .frame(width:80) .overlay( - Text("\(calculator.recommendedFuse)A") + Text("\(calculator.recommendedFuseFormatted)A") .foregroundColor(.white) .fontWeight(.bold) ) @@ -698,7 +696,7 @@ struct CalculatorView: View { Group { Button(action: {}) { 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))) .fontWeight(.medium) .foregroundColor(.blue) @@ -708,7 +706,7 @@ struct CalculatorView: View { Text("•").foregroundColor(.secondary) Button(action: { beginLengthEditing() }) { - Text(String(format: "%.1f %@", calculator.length, unitSettings.unitSystem.lengthUnit)) + Text(String(format: "%.1f %@", displayLength, unitSettings.unitSystem.lengthUnit)) .fontWeight(.medium) .foregroundColor(.primary) } @@ -775,13 +773,9 @@ struct CalculatorView: View { } .buttonStyle(.plain) - let descriptionKey = info.affiliateURL != nil - ? "affiliate.description.with_link" - : "affiliate.description.without_link" - let description = NSLocalizedString( - descriptionKey, - comment: "Explanation text beneath the affiliate button" - ) + let description = info.affiliateURL != nil + ? String(localized: "affiliate.description.with_link", defaultValue: "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan.") + : String(localized: "affiliate.description.without_link", defaultValue: "Tapping above shows a full bill of materials with shopping searches to help you source parts.") Text(description) .font(.caption2) .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") if unitSystem == .imperial { if crossSectionValue > 0 { - crossSectionLabel = String(format: "AWG %.0f", crossSectionValue) + crossSectionLabel = "AWG \(ElectricalCalculations.formatAWG(crossSectionValue))" } else { crossSectionLabel = unknownSizeLabel } @@ -816,35 +810,29 @@ struct CalculatorView: View { let cableDetail = "\(lengthLabel) • \(crossSectionLabel)" let powerDetail = String(format: "%.0f W @ %.1f V", calculator.calculatedPower, calculator.voltage) - let fuseRating = calculator.recommendedFuse - let fuseDetailFormat = NSLocalizedString( - "bom.fuse.detail", - comment: "Description for the fuse entry in the calculator BOM" - ) + let fuseRating = calculator.recommendedFuseFormatted + let fuseDetailFormat = String(localized: "bom.fuse.detail", defaultValue: "Inline holder and %@A fuse") let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating) - let cableShoesDetailFormat = NSLocalizedString( - "bom.terminals.detail", - comment: "Description for the cable terminals entry in the calculator BOM" - ) + let cableShoesDetailFormat = String(localized: "bom.terminals.detail", defaultValue: "Ring or spade terminals sized for %@ wiring") let cableShoesDetail = String.localizedStringWithFormat(cableShoesDetailFormat, crossSectionLabel.lowercased()) let cableGaugeQuery: String if unitSystem == .imperial { - cableGaugeQuery = String(format: "AWG %.0f", crossSectionValue) + cableGaugeQuery = "AWG \(ElectricalCalculations.formatAWG(crossSectionValue))" } else { 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 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 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 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 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 ? String(format: deviceFallbackFormat, calculator.calculatedPower, calculator.voltage) : calculator.loadName @@ -1068,7 +1056,7 @@ struct CalculatorView: View { private var lengthSliderRange: ClosedRange { let baseMax = unitSettings.unitSystem == .metric ? 20.0 : 60.0 - let upperBound = max(baseMax, calculator.length) + let upperBound = max(baseMax, displayLength) return 0...upperBound } @@ -1187,22 +1175,19 @@ struct CalculatorView: View { } private var lengthSlider: some View { - let lengthTitleFormat = NSLocalizedString( - "slider.length.title", - comment: "Title format for the cable length slider" - ) - return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit), + let lengthTitleFormat = String(localized: "slider.length.title", defaultValue: "Cable Length (%@)") + return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit), value: Binding( - get: { calculator.length }, + get: { displayLength }, set: { newValue in if editingValue == .length { - calculator.length = roundToTenth(newValue) + calculator.length = metersFromDisplayLength(roundToTenth(newValue)) } else { - calculator.length = normalizedLength(for: newValue) + calculator.length = metersFromDisplayLength(normalizedLength(for: newValue)) } } - ), - range: lengthSliderRange, + ), + range: lengthSliderRange, unit: unitSettings.unitSystem.lengthUnit, tapAction: beginLengthEditing, snapValues: editingValue == .length ? nil : lengthSnapValues) @@ -1306,6 +1291,21 @@ struct CalculatorView: View { 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 { max(0, (value / 5).rounded() * 5) } @@ -1342,7 +1342,7 @@ struct CalculatorView: View { } private func beginLengthEditing() { - lengthInput = formattedValue(calculator.length) + lengthInput = formattedValue(displayLength) editingValue = .length } @@ -1433,16 +1433,10 @@ private struct BillOfMaterialsView: View { let destinationURL = destinationURL(for: item) let accessibilityLabel: String = { if isCompleted { - let format = NSLocalizedString( - "bom.accessibility.mark.incomplete", - comment: "Accessibility label to mark a BOM item incomplete" - ) + let format = String(localized: "bom.accessibility.mark.incomplete", defaultValue: "Mark %@ incomplete") return String.localizedStringWithFormat(format, item.title) } else { - let format = NSLocalizedString( - "bom.accessibility.mark.complete", - comment: "Accessibility label to mark a BOM item complete" - ) + let format = String(localized: "bom.accessibility.mark.complete", defaultValue: "Mark %@ complete") return String.localizedStringWithFormat(format, item.title) } }() diff --git a/Cable/Loads/ComponentLibraryView.swift b/Cable/Loads/ComponentLibraryView.swift index 71907da..75bc8ed 100644 --- a/Cable/Loads/ComponentLibraryView.swift +++ b/Cable/Loads/ComponentLibraryView.swift @@ -211,6 +211,12 @@ final class ComponentLibraryViewModel: ObservableObject { self.urlSession = urlSession } + init(previewItems: [ComponentLibraryItem]) { + self.urlSession = .shared + self.items = previewItems + self.isLoading = false + } + func load() async { guard !isLoading else { return } isLoading = true @@ -528,9 +534,19 @@ final class ComponentLibraryViewModel: ObservableObject { struct ComponentLibraryView: View { @Environment(\.dismiss) private var dismiss - @StateObject private var viewModel = ComponentLibraryViewModel() + @StateObject private var viewModel: ComponentLibraryViewModel @State private var searchText: String = "" 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 { NavigationStack { diff --git a/Cable/Loads/ElectricalCalculations.swift b/Cable/Loads/ElectricalCalculations.swift index 3da8bdb..238bc81 100644 --- a/Cable/Loads/ElectricalCalculations.swift +++ b/Cable/Loads/ElectricalCalculations.swift @@ -17,24 +17,26 @@ struct ElectricalCalculations { 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] = [ 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] = [ - 1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, + private static let standardFuses: [Double] = [ + 1, 2, 3, 5, 7.5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 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( length: Double, current: Double, voltage: Double, unitSystem: UnitSystem ) -> Double { - let lengthInMeters = unitSystem == .metric ? length : length * feetToMeters + let lengthInMeters = length let maxVoltageDrop = voltage * maxVoltageDropFraction let minimumCrossSection = guardAgainstZero(maxVoltageDrop) { (2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop @@ -65,7 +67,7 @@ struct ElectricalCalculations { unitSystem: unitSystem ) - let lengthInMeters = unitSystem == .metric ? length : length * feetToMeters + let lengthInMeters = length let crossSectionMM2: Double if unitSystem == .metric { crossSectionMM2 = selectedCrossSection @@ -112,8 +114,8 @@ struct ElectricalCalculations { return current * drop } - static func recommendedFuse(forCurrent current: Double) -> Int { - let target = Int((current * 1.25).rounded(.up)) + static func recommendedFuse(forCurrent current: Double) -> Double { + let target = (current * 1.25).rounded(.up) return standardFuses.first(where: { $0 >= target }) ?? standardFuses.last ?? target } @@ -123,16 +125,22 @@ struct ElectricalCalculations { } private static func crossSectionFromAWG(_ awg: Double) -> Double { - switch awg { - case 00: return 67.4 - case 000: return 85.0 - case 0000: return 107.0 - default: - let index = standardAWG.firstIndex(of: Int(awg)) ?? -1 - if index >= 0 && index < awgCrossSections.count { - return awgCrossSections[index] - } - return 0.75 + let index = standardAWG.firstIndex(of: Int(awg)) ?? -1 + if index >= 0 && index < awgCrossSections.count { + return awgCrossSections[index] + } + return 0.75 + } + + /// Formats an AWG value for display: positive values as-is, negative as "X/0" notation. + static func formatAWG(_ awg: Double) -> String { + let intAWG = Int(awg) + switch intAWG { + case -1: return "1/0" + case -2: return "2/0" + case -3: return "3/0" + case -4: return "4/0" + default: return "\(intAWG)" } } } diff --git a/Cable/Loads/LoadConfigurationStatus.swift b/Cable/Loads/LoadConfigurationStatus.swift index ae1b848..54143d2 100644 --- a/Cable/Loads/LoadConfigurationStatus.swift +++ b/Cable/Loads/LoadConfigurationStatus.swift @@ -27,11 +27,9 @@ enum LoadConfigurationStatus: Identifiable, Equatable { var bannerText: String { switch self { case .missingDetails: - return NSLocalizedString( - "loads.overview.status.missing_details.banner", - bundle: .main, - value: "Finish configuring your loads", - comment: "Short banner text describing loads that need additional details" + return String( + localized: "loads.overview.status.missing_details.banner", + defaultValue: "Finish configuring your loads" ) } } @@ -39,30 +37,22 @@ enum LoadConfigurationStatus: Identifiable, Equatable { func detailInfo() -> LoadStatusDetail { switch self { case .missingDetails(let count): - let title = NSLocalizedString( - "loads.overview.status.missing_details.title", - bundle: .main, - value: "Missing load details", - comment: "Alert title when loads are missing required details" + let title = String( + localized: "loads.overview.status.missing_details.title", + defaultValue: "Missing load details" ) - let format = NSLocalizedString( - "loads.overview.status.missing_details.message", - bundle: .main, - value: "Enter cable length and wire size for %d %@ to see accurate recommendations.", - comment: "Alert message when loads are missing required details" + let format = String( + localized: "loads.overview.status.missing_details.message", + defaultValue: "Enter cable length and wire size for %d %@ to see accurate recommendations." ) let loadWord = count == 1 - ? NSLocalizedString( - "loads.overview.status.missing_details.singular", - bundle: .main, - value: "load", - comment: "Singular noun for load" + ? String( + localized: "loads.overview.status.missing_details.singular", + defaultValue: "load" ) - : NSLocalizedString( - "loads.overview.status.missing_details.plural", - bundle: .main, - value: "loads", - comment: "Plural noun for loads" + : String( + localized: "loads.overview.status.missing_details.plural", + defaultValue: "loads" ) let message = String(format: format, count, loadWord) return LoadStatusDetail(title: title, message: message) diff --git a/Cable/Loads/LoadsView.swift b/Cable/Loads/LoadsView.swift index cda9a97..0581f77 100644 --- a/Cable/Loads/LoadsView.swift +++ b/Cable/Loads/LoadsView.swift @@ -21,7 +21,7 @@ struct LoadsView: View { @State private var hasOpenedLoadOnAppear = false @State private var showingComponentLibrary = 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 chargerDraft: ChargerConfiguration? @State private var activeStatus: LoadConfigurationStatus? @@ -36,10 +36,11 @@ struct LoadsView: View { private let presentSystemEditorOnAppear: Bool 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.presentSystemEditorOnAppear = presentSystemEditorOnAppear self.loadToOpenOnAppear = loadToOpenOnAppear + self._selectedComponentTab = State(initialValue: initialTab) } private var savedLoads: [SavedLoad] { @@ -312,12 +313,7 @@ struct LoadsView: View { message: Text(detail.message), dismissButton: .default( Text( - NSLocalizedString( - "battery.bank.status.dismiss", - bundle: .main, - value: "Got it", - comment: "Dismiss button title for load status alert" - ) + String(localized: "battery.bank.status.dismiss", defaultValue: "Got it") ) ) ) @@ -610,7 +606,7 @@ struct LoadsView: View { private func wireGaugeString(for load: SavedLoad) -> String { if unitSettings.unitSystem == .imperial { let awgValue = awgFromCrossSection(load.crossSection) - return String(format: "%.0f AWG", awgValue) + return "\(ElectricalCalculations.formatAWG(awgValue)) AWG" } else { return String(format: "%.1f mm²", load.crossSection) } @@ -679,66 +675,31 @@ struct LoadsView: View { } private var fuseMetricLabel: String { - NSLocalizedString( - "loads.metric.fuse", - bundle: .main, - value: "Fuse", - comment: "Label for fuse metric in load detail row" - ) + String(localized: "loads.metric.fuse", defaultValue: "Fuse") } private var cableMetricLabel: String { - NSLocalizedString( - "loads.metric.cable", - bundle: .main, - value: "Cable", - comment: "Label for cable metric in load detail row" - ) + String(localized: "loads.metric.cable", defaultValue: "Cable") } private var lengthMetricLabel: String { - NSLocalizedString( - "loads.metric.length", - bundle: .main, - value: "Length", - comment: "Label for cable length metric in load detail row" - ) + String(localized: "loads.metric.length", defaultValue: "Length") } private var loadsSummaryTitle: String { - NSLocalizedString( - "loads.overview.header.title", - bundle: .main, - value: "Load Overview", - comment: "Title for the loads overview summary section" - ) + String(localized: "loads.overview.header.title", defaultValue: "Load Overview") } private var loadsCountLabel: String { - NSLocalizedString( - "loads.overview.metric.count", - bundle: .main, - value: "Loads", - comment: "Label for number of loads metric" - ) + String(localized: "loads.overview.metric.count", defaultValue: "Loads") } private var loadsCurrentLabel: String { - NSLocalizedString( - "loads.overview.metric.current", - bundle: .main, - value: "Total Current", - comment: "Label for total load current metric" - ) + String(localized: "loads.overview.metric.current", defaultValue: "Total Current") } private var loadsPowerLabel: String { - NSLocalizedString( - "loads.overview.metric.power", - bundle: .main, - value: "Total Power", - comment: "Label for total load power metric" - ) + String(localized: "loads.overview.metric.power", defaultValue: "Total Power") } private var totalCurrent: Double { @@ -1047,20 +1008,21 @@ struct LoadsView: View { } private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double { - let awgSizes = [(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31), - (10, 5.26), (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6), - (1, 42.4), (0, 53.5), (00, 67.4), (000, 85.0), (0000, 107.0)] - + 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), + (1, 42.4), (-1, 53.5), (-2, 67.4), (-3, 85.0), (-4, 107.0)] + // Find the closest AWG size let closest = awgSizes.min { abs($0.1 - crossSectionMM2) < abs($1.1 - crossSectionMM2) } return Double(closest?.0 ?? 20) } - - private func recommendedFuse(for load: SavedLoad) -> Int { - ElectricalCalculations.recommendedFuse(forCurrent: load.current) + + private func recommendedFuse(for load: SavedLoad) -> String { + 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 components case batteries @@ -1146,19 +1108,6 @@ struct LoadsView: View { UIRectFill(CGRect(origin: .zero, size: size)) 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() } @@ -1268,3 +1217,4 @@ struct LoadsView: View { } } } + diff --git a/Cable/Overview/SystemOverviewPDFExporter.swift b/Cable/Overview/SystemOverviewPDFExporter.swift index f4798f0..d9be412 100644 --- a/Cable/Overview/SystemOverviewPDFExporter.swift +++ b/Cable/Overview/SystemOverviewPDFExporter.swift @@ -47,7 +47,7 @@ struct SystemOverviewPDFExporter { let voltageDrop: Double let voltageDropPercent: Double let powerLoss: Double - let recommendedFuse: Int + let recommendedFuse: Double let iconUrl: String? } @@ -77,6 +77,7 @@ struct SystemOverviewPDFExporter { static func fetchDiagramImage(snapshot: SystemSnapshot) async -> UIImage? { let payload: [String: Any] = [ "systemName": snapshot.systemName, + "source": "cable", "unitSystem": snapshot.unitSystem == .metric ? "metric" : "imperial", "loads": snapshot.loads.map { load in var dict: [String: Any] = [ @@ -777,7 +778,7 @@ struct SystemOverviewPDFExporter { (voltageLabel, "\(formatNumber(load.voltage)) V", currentLabel, "\(formatNumber(load.current)) A"), (powerLabel, formatPower(load.power), lengthLabel, "\(formatNumber(load.length)) \(lengthUnit)"), (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 { diff --git a/Cable/Overview/SystemOverviewView.swift b/Cable/Overview/SystemOverviewView.swift index 9ddf615..0f91670 100644 --- a/Cable/Overview/SystemOverviewView.swift +++ b/Cable/Overview/SystemOverviewView.swift @@ -398,11 +398,9 @@ struct SystemOverviewView: View { message: Text(detail.message), dismissButton: .default( Text( - NSLocalizedString( - "battery.bank.status.dismiss", - bundle: .main, - value: "Got it", - comment: "Dismiss button title for load status alert" + String( + localized: "battery.bank.status.dismiss", + defaultValue: "Got it" ) ) ) @@ -962,266 +960,123 @@ struct SystemOverviewView: View { } private var loadsSummaryTitle: String { - NSLocalizedString( - "loads.overview.header.title", - bundle: .main, - value: "Load Overview", - comment: "Title for the loads overview summary section" - ) + String(localized: "loads.overview.header.title", defaultValue: "Load Overview") } private var loadsCountLabel: String { - NSLocalizedString( - "loads.overview.metric.count", - bundle: .main, - value: "Loads", - comment: "Label for number of loads metric" - ) + String(localized: "loads.overview.metric.count", defaultValue: "Loads") } private var loadsCurrentLabel: String { - NSLocalizedString( - "loads.overview.metric.current", - bundle: .main, - value: "Total Current", - comment: "Label for total load current metric" - ) + String(localized: "loads.overview.metric.current", defaultValue: "Total Current") } private var loadsPowerLabel: String { - NSLocalizedString( - "loads.overview.metric.power", - bundle: .main, - value: "Total Power", - comment: "Label for total load power metric" - ) + String(localized: "loads.overview.metric.power", defaultValue: "Total Power") } private var loadsEmptyTitle: String { - NSLocalizedString( - "overview.loads.empty.title", - bundle: .main, - value: "No loads configured yet", - comment: "Title shown in overview when no loads exist" - ) + String(localized: "overview.loads.empty.title", defaultValue: "No loads configured yet") } private var loadsEmptySubtitle: String { - NSLocalizedString( - "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" - ) + String(localized: "overview.loads.empty.subtitle", defaultValue: "Add components to get cable sizing and fuse recommendations tailored to this system.") } private var loadsEmptyMessage: String { - NSLocalizedString( - "loads.overview.empty.message", - bundle: .main, - value: "Start by adding a load to see system insights.", - comment: "Message shown when no loads exist" - ) + String(localized: "loads.overview.empty.message", defaultValue: "Start by adding a load to see system insights.") } private var loadsEmptyCreateAction: String { - NSLocalizedString( - "loads.overview.empty.create", - bundle: .main, - value: "Create Load", - comment: "Button title to create a new load" - ) + String(localized: "loads.overview.empty.create", defaultValue: "Add Load") } private var loadsEmptyBrowseAction: String { - NSLocalizedString( - "loads.overview.empty.library", - bundle: .main, - value: "Browse Library", - comment: "Button title to open load library" - ) + String(localized: "loads.overview.empty.library", defaultValue: "Browse Library") } private var batterySummaryTitle: String { - NSLocalizedString( - "battery.bank.header.title", - bundle: .main, - value: "Battery Bank", - comment: "Title for the battery bank summary section" - ) + String(localized: "battery.bank.header.title", defaultValue: "Battery Bank") } private var batteryCountLabel: String { - NSLocalizedString( - "battery.bank.metric.count", - bundle: .main, - value: "Batteries", - comment: "Label for number of batteries metric" - ) + String(localized: "battery.bank.metric.count", defaultValue: "Batteries") } private var batteryCapacityLabel: String { - NSLocalizedString( - "battery.bank.metric.capacity", - bundle: .main, - value: "Capacity", - comment: "Label for total capacity metric" - ) + String(localized: "battery.bank.metric.capacity", defaultValue: "Capacity") } private var batteryUsableCapacityLabel: String { - NSLocalizedString( - "battery.bank.metric.usable_capacity", - bundle: .main, - value: "Usable Capacity", - comment: "Label for usable capacity metric" - ) + String(localized: "battery.bank.metric.usable_capacity", defaultValue: "Usable Capacity") } private var batteryUsableEnergyLabel: String { - NSLocalizedString( - "battery.bank.metric.usable_energy", - bundle: .main, - value: "Usable Energy", - comment: "Label for usable energy metric" - ) + String(localized: "battery.bank.metric.usable_energy", defaultValue: "Usable Energy") } private var batteryEmptyTitle: String { - NSLocalizedString( - "battery.bank.empty.title", - bundle: .main, - value: "No Batteries Yet", - comment: "Title shown when no batteries are configured" - ) + String(localized: "battery.bank.empty.title", defaultValue: "No Batteries Yet") } private var batteryEmptySubtitle: String { - let format = NSLocalizedString( - "battery.bank.empty.subtitle", - tableName: nil, - bundle: .main, - value: "Tap the plus button to configure a battery for %@.", - comment: "Subtitle shown when no batteries are configured" + let format = String( + localized: "battery.bank.empty.subtitle", + defaultValue: "Tap the plus button to configure a battery for %@." ) return String(format: format, system.name) } private var batteryEmptyCreateAction: String { - NSLocalizedString( - "battery.overview.empty.create", - bundle: .main, - value: "Create Battery", - comment: "Button title to create a new battery" - ) + String(localized: "battery.overview.empty.create", defaultValue: "Add Battery") } private var chargerSummaryTitle: String { - NSLocalizedString( - "overview.chargers.header.title", - bundle: .main, - value: "Charger Overview", - comment: "Title for the chargers summary section" - ) + String(localized: "overview.chargers.header.title", defaultValue: "Charger Overview") } private var chargerCountLabel: String { - NSLocalizedString( - "chargers.summary.metric.count", - bundle: .main, - value: "Chargers", - comment: "Label for number of chargers metric" - ) + String(localized: "chargers.summary.metric.count", defaultValue: "Chargers") } private var chargerOutputLabel: String { - NSLocalizedString( - "chargers.summary.metric.output", - bundle: .main, - value: "Output Voltage", - comment: "Label for representative output voltage metric" - ) + String(localized: "chargers.summary.metric.output", defaultValue: "Output Voltage") } private var chargerCurrentLabel: String { - NSLocalizedString( - "chargers.summary.metric.current", - bundle: .main, - value: "Charge Rate", - comment: "Label for total charger current metric" - ) + String(localized: "chargers.summary.metric.current", defaultValue: "Charge Rate") } private var chargerPowerLabel: String { - NSLocalizedString( - "chargers.summary.metric.power", - bundle: .main, - value: "Charge Power", - comment: "Label for total charger power metric" - ) + String(localized: "chargers.summary.metric.power", defaultValue: "Charge Power") } private var chargerEmptyTitle: String { - NSLocalizedString( - "overview.chargers.empty.title", - bundle: .main, - value: "No chargers configured yet", - comment: "Title shown when no chargers are configured" - ) + String(localized: "overview.chargers.empty.title", defaultValue: "No chargers configured yet") } private var chargerEmptySubtitle: String { - NSLocalizedString( - "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" - ) + String(localized: "overview.chargers.empty.subtitle", defaultValue: "Add shore power, DC-DC, or solar chargers to understand your charging capacity.") } private var chargerEmptyCreateAction: String { - NSLocalizedString( - "overview.chargers.empty.create", - bundle: .main, - value: "Add Charger", - comment: "Button title to create a charger from the overview" - ) + String(localized: "overview.chargers.empty.create", defaultValue: "Add Charger") } private var systemOverviewTitle: String { - NSLocalizedString( - "overview.system.header.title", - bundle: .main, - value: "System Overview", - comment: "Title for system overview card" - ) + String(localized: "overview.system.header.title", defaultValue: "System Overview") } private var bomTitle: String { - NSLocalizedString( - "overview.bom.title", - bundle: .main, - value: "Bill of Materials", - comment: "Title for BOM metric card in the system overview" - ) + String(localized: "overview.bom.title", defaultValue: "Bill of Materials") } private var bomSubtitle: String { - NSLocalizedString( - "overview.bom.subtitle", - bundle: .main, - value: "Tap to review components", - comment: "Subtitle describing the BOM metric card interaction" - ) + String(localized: "overview.bom.subtitle", defaultValue: "Tap to review components") } private var bomPlaceholderSummary: String { - NSLocalizedString( - "overview.bom.placeholder.short", - bundle: .main, - value: "Add loads", - comment: "Short placeholder shown when no BOM data is available" - ) + String(localized: "overview.bom.placeholder.short", defaultValue: "Add loads") } private var formattedBOMCompletedCount: String? { @@ -1235,30 +1090,15 @@ struct SystemOverviewView: View { } private var chargeTimeTitle: String { - NSLocalizedString( - "overview.chargetime.title", - bundle: .main, - value: "Estimated charge time", - comment: "Title for the charge time metric card" - ) + String(localized: "overview.chargetime.title", defaultValue: "Estimated charge time") } private var chargeTimeSubtitle: String { - NSLocalizedString( - "overview.chargetime.subtitle", - bundle: .main, - value: "At combined charge rate", - comment: "Subtitle describing charge time assumptions" - ) + String(localized: "overview.chargetime.subtitle", defaultValue: "At combined charge rate") } private var chargeTimePlaceholderSummary: String { - NSLocalizedString( - "overview.chargetime.placeholder.short", - bundle: .main, - value: "Add chargers", - comment: "Short placeholder shown when charge time cannot be calculated" - ) + String(localized: "overview.chargetime.placeholder.short", defaultValue: "Add chargers") } private var chargeGoalValueText: String? { @@ -1266,39 +1106,19 @@ struct SystemOverviewView: View { } private var goalPrefix: String { - NSLocalizedString( - "overview.goal.prefix", - bundle: .main, - value: "Goal", - comment: "Prefix displayed before goal value" - ) + String(localized: "overview.goal.prefix", defaultValue: "Goal") } private var runtimeTitle: String { - NSLocalizedString( - "overview.runtime.title", - bundle: .main, - value: "Estimated runtime", - comment: "Title for estimated runtime section" - ) + String(localized: "overview.runtime.title", defaultValue: "Estimated runtime") } private var runtimeSubtitle: String { - NSLocalizedString( - "overview.runtime.subtitle", - bundle: .main, - value: "At current load draw", - comment: "Subtitle describing runtime assumption" - ) + String(localized: "overview.runtime.subtitle", defaultValue: "At maximum load draw") } private var runtimePlaceholderSummary: String { - NSLocalizedString( - "overview.runtime.placeholder.short", - bundle: .main, - value: "Add capacity", - comment: "Short placeholder shown when runtime cannot be calculated" - ) + String(localized: "overview.runtime.placeholder.short", defaultValue: "Add capacity") } private var runtimeGoalValueText: String? { @@ -1311,48 +1131,23 @@ struct SystemOverviewView: View { } private var runtimeGoalSheetTitle: String { - NSLocalizedString( - "overview.runtime.goal.title", - bundle: .main, - value: "Runtime Goal", - comment: "Navigation title for editing the runtime goal" - ) + String(localized: "overview.runtime.goal.title", defaultValue: "Runtime Goal") } private var chargeGoalSheetTitle: String { - NSLocalizedString( - "overview.chargetime.goal.title", - bundle: .main, - value: "Charge Goal", - comment: "Navigation title for editing the charge time goal" - ) + String(localized: "overview.chargetime.goal.title", defaultValue: "Charge Goal") } private var goalClearTitle: String { - NSLocalizedString( - "overview.goal.clear", - bundle: .main, - value: "Remove Goal", - comment: "Button title to clear an active goal" - ) + String(localized: "overview.goal.clear", defaultValue: "Remove Goal") } private var goalCancelTitle: String { - NSLocalizedString( - "overview.goal.cancel", - bundle: .main, - value: "Cancel", - comment: "Button title to cancel goal editing" - ) + String(localized: "overview.goal.cancel", defaultValue: "Cancel") } private var goalSaveTitle: String { - NSLocalizedString( - "overview.goal.save", - bundle: .main, - value: "Save", - comment: "Button title to save goal editing" - ) + String(localized: "overview.goal.save", defaultValue: "Save") } private static let numberFormatter: NumberFormatter = { @@ -1405,19 +1200,9 @@ struct SystemOverviewView: View { var shortLabel: String { switch self { case .voltage: - return NSLocalizedString( - "battery.bank.warning.voltage.short", - bundle: .main, - value: "Voltage", - comment: "Short label for voltage warning" - ) + return String(localized: "battery.bank.warning.voltage.short", defaultValue: "Voltage") case .capacity: - return NSLocalizedString( - "battery.bank.warning.capacity.short", - bundle: .main, - value: "Capacity", - comment: "Short label for capacity warning" - ) + return String(localized: "battery.bank.warning.capacity.short", defaultValue: "Capacity") } } } diff --git a/Cable/Systems/SystemBillOfMaterialsPDFExporter.swift b/Cable/Systems/SystemBillOfMaterialsPDFExporter.swift index 4a09053..6efab68 100644 --- a/Cable/Systems/SystemBillOfMaterialsPDFExporter.swift +++ b/Cable/Systems/SystemBillOfMaterialsPDFExporter.swift @@ -36,9 +36,9 @@ struct SystemBillOfMaterialsPDFExporter { systemName: systemName, unitSystem: unitSystem ) - let emptyMessage = NSLocalizedString( - "bom.pdf.placeholder.empty", - comment: "Message shown in the PDF export when no components are available" + let emptyMessage = String( + localized: "bom.pdf.placeholder.empty", + defaultValue: "No components available." ) drawPlaceholder(in: context.cgContext, text: emptyMessage, at: cursorY) } else { @@ -89,17 +89,17 @@ struct SystemBillOfMaterialsPDFExporter { let titleFont = UIFont.systemFont(ofSize: isFirstPage ? 26 : 18, weight: .bold) let subtitleFont = UIFont.systemFont(ofSize: isFirstPage ? 16 : 12, weight: .medium) let title = isFirstPage - ? NSLocalizedString( - "bom.pdf.header.title", - comment: "Primary title shown at the top of the BOM PDF" + ? String( + localized: "bom.pdf.header.title", + defaultValue: "System Bill of Materials" ) : systemName let subtitle: String if isFirstPage { - let format = NSLocalizedString( - "bom.pdf.header.subtitle", - comment: "Subtitle format combining system name and unit system for the BOM PDF" + let format = String( + localized: "bom.pdf.header.subtitle", + defaultValue: "%@ • %@" ) subtitle = String( format: format, @@ -108,9 +108,9 @@ struct SystemBillOfMaterialsPDFExporter { unitSystem.displayName ) } else { - let format = NSLocalizedString( - "bom.pdf.header.inline", - comment: "Subtitle describing the active unit system on subsequent PDF pages" + let format = String( + localized: "bom.pdf.header.inline", + defaultValue: "Unit System: %@" ) subtitle = String( format: format, @@ -286,9 +286,9 @@ struct SystemBillOfMaterialsPDFExporter { .font: footerFont, .foregroundColor: tertiaryTextColor ] - let format = NSLocalizedString( - "bom.pdf.page.number", - comment: "Format string for the PDF page number footer" + let format = String( + localized: "bom.pdf.page.number", + defaultValue: "Page %d" ) let text = String(format: format, locale: Locale.current, pageIndex) let size = text.size(withAttributes: attributes) diff --git a/Cable/Systems/SystemBillOfMaterialsView.swift b/Cable/Systems/SystemBillOfMaterialsView.swift index c7656a1..cd6e0d3 100644 --- a/Cable/Systems/SystemBillOfMaterialsView.swift +++ b/Cable/Systems/SystemBillOfMaterialsView.swift @@ -40,60 +40,30 @@ struct SystemBillOfMaterialsView: View { var title: String { switch self { case .components: - return NSLocalizedString( - "bom.category.components.title", - comment: "Section title for core components and chargers" - ) + return String(localized: "bom.category.components.title", defaultValue: "Components & Chargers") case .batteries: - return NSLocalizedString( - "bom.category.batteries.title", - comment: "Section title for batteries" - ) + return String(localized: "bom.category.batteries.title", defaultValue: "Batteries") case .cables: - return NSLocalizedString( - "bom.category.cables.title", - comment: "Section title for power cables" - ) + return String(localized: "bom.category.cables.title", defaultValue: "Cables") case .fuses: - return NSLocalizedString( - "bom.category.fuses.title", - comment: "Section title for fuses and holders" - ) + return String(localized: "bom.category.fuses.title", defaultValue: "Fuses") case .accessories: - return NSLocalizedString( - "bom.category.accessories.title", - comment: "Section title for accessory hardware" - ) + return String(localized: "bom.category.accessories.title", defaultValue: "Accessories") } } var subtitle: String { switch self { case .components: - return NSLocalizedString( - "bom.category.components.subtitle", - comment: "Subtitle describing the components section" - ) + return String(localized: "bom.category.components.subtitle", defaultValue: "Primary devices, controllers, and charging gear.") case .batteries: - return NSLocalizedString( - "bom.category.batteries.subtitle", - comment: "Subtitle describing the batteries section" - ) + return String(localized: "bom.category.batteries.subtitle", defaultValue: "House banks and storage.") case .cables: - return NSLocalizedString( - "bom.category.cables.subtitle", - comment: "Subtitle describing the cables section" - ) + return String(localized: "bom.category.cables.subtitle", defaultValue: "Sized power runs for every circuit.") case .fuses: - return NSLocalizedString( - "bom.category.fuses.subtitle", - comment: "Subtitle describing the fuses section" - ) + return String(localized: "bom.category.fuses.subtitle", defaultValue: "Circuit protection and holders.") case .accessories: - return NSLocalizedString( - "bom.category.accessories.subtitle", - comment: "Subtitle describing the accessories section" - ) + return String(localized: "bom.category.accessories.subtitle", defaultValue: "Fuses, lugs, and supporting hardware.") } } @@ -165,44 +135,26 @@ struct SystemBillOfMaterialsView: View { if let scale = quantityScale { guard let unit = quantifiedDetailContext else { return nil } let value = Double(quantity) / scale - let format = NSLocalizedString( - "bom.quantity.cable.badge", - comment: "Metric text for total cable length including cross section" - ) + let format = String(localized: "bom.quantity.cable.badge", defaultValue: "%1$.1f %2$@ · %3$@") return String(format: format, locale: Locale.current, value, unit, quantifiedDetailSecondaryContext ?? "") } else if quantity > 1 { if let fuseRating = quantifiedDetailSecondaryContext, let amps = Int(fuseRating) { - let format = NSLocalizedString( - "bom.quantity.fuse.badge", - comment: "Metric text for consolidated fuses" - ) + let format = String(localized: "bom.quantity.fuse.badge", defaultValue: "%1$d× · %2$d A") return String(format: format, quantity, amps) } if let gauge = quantifiedDetailContext { - let format = NSLocalizedString( - "bom.quantity.terminal.badge", - comment: "Metric text for consolidated terminals" - ) + let format = String(localized: "bom.quantity.terminal.badge", defaultValue: "%1$d× · %2$@") return String(format: format, quantity, gauge) } - let format = NSLocalizedString( - "bom.quantity.count.badge", - comment: "Metric text for counted items" - ) + let format = String(localized: "bom.quantity.count.badge", defaultValue: "%d×") return String(format: format, quantity) } if let fuseRating = quantifiedDetailSecondaryContext, let amps = Int(fuseRating) { - let format = NSLocalizedString( - "bom.quantity.fuse.badge", - comment: "Metric text for consolidated fuses" - ) + let format = String(localized: "bom.quantity.fuse.badge", defaultValue: "%1$d× · %2$d A") return String(format: format, 1, amps) } if let gauge = quantifiedDetailContext, !gauge.isEmpty { - let format = NSLocalizedString( - "bom.quantity.single.badge", - comment: "Metric text when quantity is one but should be explicit" - ) + let format = String(localized: "bom.quantity.single.badge", defaultValue: "1× • %@") return String(format: format, gauge) } return nil @@ -313,10 +265,7 @@ struct SystemBillOfMaterialsView: View { .listStyle(.insetGrouped) .navigationTitle( String( - format: NSLocalizedString( - "bom.navigation.title.system", - comment: "Navigation title for the bill of materials view" - ), + format: String(localized: "bom.navigation.title.system", defaultValue: "BOM – %@"), locale: Locale.current, systemName ) @@ -337,10 +286,7 @@ struct SystemBillOfMaterialsView: View { .progressViewStyle(.circular) } else { Label( - NSLocalizedString( - "bom.export.pdf.button", - comment: "Button title for exporting the BOM as a PDF document" - ), + String(localized: "bom.export.pdf.button", defaultValue: "Export PDF"), systemImage: "square.and.arrow.up" ) } @@ -361,18 +307,12 @@ struct SystemBillOfMaterialsView: View { .alert(item: $exportError) { error in Alert( title: Text( - NSLocalizedString( - "bom.export.pdf.error.title", - comment: "Title for the alert shown when a PDF export fails" - ) + String(localized: "bom.export.pdf.error.title", defaultValue: "Export Failed") ), message: Text(error.message), dismissButton: .default( Text( - NSLocalizedString( - "generic.ok", - comment: "Default acknowledgement button title" - ) + String(localized: "generic.ok", defaultValue: "OK") ) ) ) @@ -382,10 +322,7 @@ struct SystemBillOfMaterialsView: View { private func exportBillOfMaterialsPDF() { if categorySections.isEmpty { exportError = ExportError( - message: NSLocalizedString( - "bom.export.pdf.error.empty", - comment: "Error message shown when attempting to export a PDF without any components" - ) + message: String(localized: "bom.export.pdf.error.empty", defaultValue: "Add at least one component before exporting.") ) return } @@ -436,11 +373,9 @@ struct SystemBillOfMaterialsView: View { HStack(spacing: 12) { let accessibilityLabel: String = { - let formatKey = isCompleted ? "bom.accessibility.mark.incomplete" : "bom.accessibility.mark.complete" - let format = NSLocalizedString( - formatKey, - comment: "Accessibility label instructing VoiceOver to toggle a BOM item" - ) + let format = isCompleted + ? String(localized: "bom.accessibility.mark.incomplete", defaultValue: "Mark %@ incomplete") + : String(localized: "bom.accessibility.mark.complete", defaultValue: "Mark %@ complete") return String.localizedStringWithFormat(format, item.title) }() @@ -620,9 +555,9 @@ struct SystemBillOfMaterialsView: View { if unitSystem == .imperial { let awg = awgFromCrossSection(load.crossSection) - if awg > 0 { - crossSectionLabel = String(format: "AWG %.0f", awg) - gaugeQuery = String(format: "AWG %.0f", awg) + if load.crossSection > 0 { + crossSectionLabel = "AWG \(ElectricalCalculations.formatAWG(awg))" + gaugeQuery = "AWG \(ElectricalCalculations.formatAWG(awg))" } else { crossSectionLabel = unknownSizeLabel gaugeQuery = "battery cable" @@ -641,34 +576,28 @@ struct SystemBillOfMaterialsView: View { let powerDetail = String(format: "%.0f W @ %.1f V", calculatedPower, load.voltage) let fuseRating = recommendedFuse(for: load) - let fuseDetailFormat = NSLocalizedString( - "bom.fuse.detail", - comment: "Description for the fuse item in the BOM list" - ) + let fuseDetailFormat = String(localized: "bom.fuse.detail", defaultValue: "Inline holder and %dA fuse") let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating) let terminalCount = 4 - let cableShoesDetailFormat = NSLocalizedString( - "bom.terminals.detail", - comment: "Description for the cable terminals item in the BOM list" - ) + let cableShoesDetailFormat = String(localized: "bom.terminals.detail", defaultValue: "Ring or spade terminals sized for %@ wiring") let cableShoesDetail = String.localizedStringWithFormat( cableShoesDetailFormat, crossSectionLabel.lowercased() ) 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 ? String(format: deviceFallbackFormat, calculatedPower, load.voltage) : 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 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 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 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 componentStorageKey = Self.storageKey(for: component, itemID: "component") @@ -775,19 +704,13 @@ struct SystemBillOfMaterialsView: View { private func batteryItems(for battery: SavedBattery) -> [Item] { let component: Component = .battery(battery) - let usableCapacityLabel = NSLocalizedString( - "battery.bank.metric.usable_capacity", - comment: "Label describing usable battery capacity" - ) + let usableCapacityLabel = String(localized: "battery.bank.metric.usable_capacity", defaultValue: "Usable Capacity") let capacityLabel = String(format: "%.0f Ah @ %.1f V", battery.capacityAmpHours, battery.nominalVoltage) let usableLabel = String(format: "%@: %.0f Ah", usableCapacityLabel, battery.usableCapacityAmpHours) let detail = [capacityLabel, battery.chemistry.displayName, usableLabel].joined(separator: " • ") let capacityQuery = max(1, Int(round(battery.capacityAmpHours))) let voltageQuery = max(1, Int(round(battery.nominalVoltage))) - let batterySearchFormat = NSLocalizedString( - "bom.search.battery", - comment: "Amazon search query for a battery" - ) + let batterySearchFormat = String(localized: "bom.search.battery", defaultValue: "%dAh %dV %@ battery") let query = String(format: batterySearchFormat, capacityQuery, voltageQuery, battery.chemistry.displayName) let affiliateURL = battery.affiliateURLString.flatMap { URL(string: $0) } let storageKey = Self.storageKey(for: component, itemID: "battery") @@ -933,15 +856,16 @@ struct SystemBillOfMaterialsView: View { completedItemIDs = Set(loadKeys + batteryKeys + chargerKeys) } - private func recommendedFuse(for load: SavedLoad) -> Int { - ElectricalCalculations.recommendedFuse(forCurrent: load.current) + private func recommendedFuse(for load: SavedLoad) -> String { + 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 { let mapping: [(awg: Double, area: 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), (1, 42.4), (0, 53.5), - (00, 67.4), (000, 85.0), (0000, 107.0) + (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6), (1, 42.4), (-1, 53.5), + (-2, 67.4), (-3, 85.0), (-4, 107.0) ] guard crossSectionMM2 > 0 else { return 0 } @@ -954,10 +878,7 @@ struct SystemBillOfMaterialsView: View { } private var footerMessage: String { - NSLocalizedString( - "affiliate.disclaimer", - comment: "Footer note reminding users that affiliate purchases may support the app" - ) + String(localized: "affiliate.disclaimer", defaultValue: "Purchases through affiliate links may support VoltPlan.") } private var dateFormatter: DateFormatter { diff --git a/Cable/Systems/SystemComponentsPersistence.swift b/Cable/Systems/SystemComponentsPersistence.swift index 4d7f25b..2a340dd 100644 --- a/Cable/Systems/SystemComponentsPersistence.swift +++ b/Cable/Systems/SystemComponentsPersistence.swift @@ -98,11 +98,9 @@ struct SystemComponentsPersistence { existingBatteries: [SavedBattery], existingChargers: [SavedCharger] ) -> BatteryConfiguration { - let defaultName = NSLocalizedString( - "battery.editor.default_name", - bundle: .main, - value: "New Battery", - comment: "Default name when configuring a new battery" + let defaultName = String( + localized: "battery.editor.default_name", + defaultValue: "New Battery" ) let batteryName = uniqueName( startingWith: defaultName, @@ -124,11 +122,9 @@ struct SystemComponentsPersistence { existingBatteries: [SavedBattery], existingChargers: [SavedCharger] ) -> ChargerConfiguration { - let defaultName = NSLocalizedString( - "charger.editor.default_name", - bundle: .main, - value: "New Charger", - comment: "Default name when configuring a new charger" + let defaultName = String( + localized: "charger.editor.default_name", + defaultValue: "New Charger" ) let chargerName = uniqueName( startingWith: defaultName, diff --git a/Cable/Systems/SystemsView.swift b/Cable/Systems/SystemsView.swift index 630be42..eb3b34d 100644 --- a/Cable/Systems/SystemsView.swift +++ b/Cable/Systems/SystemsView.swift @@ -472,10 +472,7 @@ struct SystemsView: View { formattedPower = String(format: "%.0fW", totalPower) } - let format = NSLocalizedString( - "system.list.component.summary", - comment: "Summary showing number of components and the total power" - ) + let format = String(localized: "system.list.component.summary", defaultValue: "%#@component_count@ • %@") return String.localizedStringWithFormat(format, count, formattedPower) } @@ -499,12 +496,10 @@ struct SystemsView: View { private func keywords(for localizationKey: String, fallback: [String]) -> [String] { let fallbackValue = fallback.joined(separator: ",") - let localizedKeywords = NSLocalizedString( - localizationKey, - tableName: nil, - bundle: .main, + let localizedKeywords = Bundle.main.localizedString( + forKey: localizationKey, value: fallbackValue, - comment: "" + table: nil ) let separators = CharacterSet(charactersIn: ",;") let components = localizedKeywords diff --git a/Cable/UITestSampleData.swift b/Cable/UITestSampleData.swift index d83c9d5..ae930d8 100644 --- a/Cable/UITestSampleData.swift +++ b/Cable/UITestSampleData.swift @@ -207,7 +207,8 @@ extension UITestSampleData { iconName: "bolt.badge.clock", colorName: "blue", system: adventureVan, - identifier: "sample.charger.dcdc" + identifier: "sample.charger.dcdc", + powerSourceType: "alternator" ) alternatorCharger.timestamp = Date(timeIntervalSinceReferenceDate: 1350) diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index a0f3114..f96040e 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -84,7 +84,7 @@ "battery.bank.status.voltage.title" = "Spannungsabweichung"; "battery.bank.warning.capacity.short" = "Kapazität"; "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.charge_voltage.helper" = "Lege die maximal empfohlene Ladespannung fest."; "battery.editor.advanced.cutoff_voltage.helper" = "Lege die minimale sichere Entladespannung fest."; @@ -442,6 +442,7 @@ "charger.source.solar" = "Solar"; "charger.source.wind" = "Wind"; "charger.source.generator" = "Generator"; +"charger.source.alternator" = "Lichtmaschine"; // MARK: - Share Menu "overview.share.diagram" = "Schaltplan"; diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings index 61d7465..929b243 100644 --- a/Cable/es.lproj/Localizable.strings +++ b/Cable/es.lproj/Localizable.strings @@ -207,7 +207,7 @@ "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.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.bom.title" = "Lista de materiales"; "overview.bom.subtitle" = "Pulsa para revisar los componentes"; @@ -273,7 +273,7 @@ "battery.editor.slider.temperature_range.max" = "Máximo"; "battery.editor.section.advanced" = "Avanzado"; "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.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."; @@ -404,6 +404,7 @@ "charger.source.solar" = "Solar"; "charger.source.wind" = "Eólica"; "charger.source.generator" = "Generador"; +"charger.source.alternator" = "Alternador"; // MARK: - Share Menu "overview.share.diagram" = "Diagrama de cableado"; diff --git a/Cable/fr.lproj/Localizable.strings b/Cable/fr.lproj/Localizable.strings index 18bcae5..9efbcb6 100644 --- a/Cable/fr.lproj/Localizable.strings +++ b/Cable/fr.lproj/Localizable.strings @@ -207,7 +207,7 @@ "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.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.bom.title" = "Liste de matériel"; "overview.bom.subtitle" = "Touchez pour consulter les composants"; @@ -273,7 +273,7 @@ "battery.editor.slider.temperature_range.max" = "Maximum"; "battery.editor.section.advanced" = "Avancé"; "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.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."; @@ -404,6 +404,7 @@ "charger.source.solar" = "Solaire"; "charger.source.wind" = "Éolienne"; "charger.source.generator" = "Groupe électrogène"; +"charger.source.alternator" = "Alternateur"; // MARK: - Share Menu "overview.share.diagram" = "Schéma de câblage"; diff --git a/Cable/nl.lproj/Localizable.strings b/Cable/nl.lproj/Localizable.strings index 6ae0fe8..be58fef 100644 --- a/Cable/nl.lproj/Localizable.strings +++ b/Cable/nl.lproj/Localizable.strings @@ -207,7 +207,7 @@ "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.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.bom.title" = "Stuklijst"; "overview.bom.subtitle" = "Tik om componenten te bekijken"; @@ -273,8 +273,8 @@ "battery.editor.slider.temperature_range.max" = "Maximum"; "battery.editor.section.advanced" = "Geavanceerd"; "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_override" = "Handmatige override actief. Chemische standaard blijft %@."; +"battery.editor.advanced.usable_capacity.footer_default" = "Standaard %@ op basis van de chemie."; +"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.cutoff_voltage.helper" = "Stel de minimale veilige ontlaadspanning in."; "battery.editor.advanced.temperature_range.helper" = "Bepaal het aanbevolen temperatuurbereik voor gebruik."; @@ -404,6 +404,7 @@ "charger.source.solar" = "Zonne-energie"; "charger.source.wind" = "Wind"; "charger.source.generator" = "Generator"; +"charger.source.alternator" = "Dynamo"; // MARK: - Share Menu "overview.share.diagram" = "Bedradingsschema"; diff --git a/CableTests/CableTests.swift b/CableTests/CableTests.swift index 3dd6675..3634ee9 100644 --- a/CableTests/CableTests.swift +++ b/CableTests/CableTests.swift @@ -10,7 +10,12 @@ import Testing 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 { + // 10m, 5A, 12V → minCS = (2×5×10×0.017)/(12×0.05) = 2.833mm² → rounds up to 4.0mm² let crossSection = ElectricalCalculations.recommendedCrossSection( length: 10, current: 5, @@ -19,6 +24,7 @@ struct CableTests { ) #expect(crossSection == 4.0) + // V_drop = (2×5×10×0.017)/4.0 = 0.425V let voltageDrop = ElectricalCalculations.voltageDrop( length: 10, current: 5, @@ -27,6 +33,7 @@ struct CableTests { ) #expect(abs(voltageDrop - 0.425) < 0.001) + // 0.425/12 × 100 = 3.5417% let dropPercentage = ElectricalCalculations.voltageDropPercentage( length: 10, current: 5, @@ -35,6 +42,7 @@ struct CableTests { ) #expect(abs(dropPercentage - 3.5417) < 0.001) + // P_loss = I × V_drop = 5 × 0.425 = 2.125W let powerLoss = ElectricalCalculations.powerLoss( length: 10, current: 5, @@ -44,42 +52,364 @@ struct CableTests { #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 { + let lengthInMeters = 25.0 * 0.3048 // 25 ft = 7.62 m let awg = ElectricalCalculations.recommendedCrossSection( - length: 25, + length: lengthInMeters, current: 15, voltage: 120, unitSystem: .imperial ) + // minCS = (2×15×7.62×0.017)/(120×0.05) = 0.648mm² → AWG 18 (0.823mm²) #expect(awg == 18.0) let voltageDrop = ElectricalCalculations.voltageDrop( - length: 25, + length: lengthInMeters, current: 15, voltage: 120, unitSystem: .imperial ) + // (2×15×7.62×0.017)/0.823 = 4.722V #expect(abs(voltageDrop - 4.722) < 0.01) let dropPercentage = ElectricalCalculations.voltageDropPercentage( - length: 25, + length: lengthInMeters, current: 15, voltage: 120, unitSystem: .imperial ) + // 4.722/120 × 100 = 3.935% #expect(abs(dropPercentage - 3.935) < 0.01) let powerLoss = ElectricalCalculations.powerLoss( - length: 25, + length: lengthInMeters, current: 15, voltage: 120, unitSystem: .imperial ) + // 15 × 4.722 = 70.83W #expect(abs(powerLoss - 70.83) < 0.05) } + // MARK: - Fuse Sizing + @Test func recommendedFuseRoundsUpToNearestStandardSize() async throws { - #expect(ElectricalCalculations.recommendedFuse(forCurrent: 7.2) == 10) - #expect(ElectricalCalculations.recommendedFuse(forCurrent: 59.0) == 80) + // 7.2A × 1.25 = 9.0 → next fuse ≥ 9 = 10A + #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) } }