Optimize affiliate system and add locale-aware defaults
- Show BOM button for unsaved loads (no longer requires save first) - Set US fallback affiliate tag for unknown countries - Localize Amazon search queries in all 5 languages (EN/DE/ES/FR/NL) - Add affiliate URL/country fields to SavedBattery model - Auto-detect unit system (imperial for US locale, metric otherwise) - Set charger input voltage based on locale (120V US, 230V EU) - Remove StoreKitManager and CableProPaywallView - Add CLAUDE.md project instructions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
63
CLAUDE.md
Normal file
63
CLAUDE.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
# Build
|
||||
xcodebuild -scheme Cable -destination 'platform=iOS Simulator,name=iPhone 15' build
|
||||
|
||||
# Run all tests (unit + UI)
|
||||
xcodebuild -scheme Cable -destination 'platform=iOS Simulator,name=iPhone 15' test
|
||||
|
||||
# Run only unit tests
|
||||
xcodebuild -scheme Cable -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:CableTests test
|
||||
|
||||
# Open in Xcode (preferred for UI iteration with previews)
|
||||
open Cable.xcodeproj
|
||||
```
|
||||
|
||||
Requires Xcode 15+ and iOS 17 simulator. No external dependencies beyond the Xcode toolchain.
|
||||
|
||||
## Architecture
|
||||
|
||||
**SwiftUI + SwiftData app** for sizing low-voltage electrical conductors (boats, RVs, off-grid).
|
||||
|
||||
### Data Model Hierarchy
|
||||
|
||||
`ElectricalSystem` (top-level container) owns collections of:
|
||||
- `SavedLoad` — individual electrical loads with wire sizing parameters
|
||||
- `SavedBattery` — battery banks with chemistry-specific capacity rules
|
||||
- `SavedCharger` — charging equipment specs
|
||||
|
||||
All are `@Model` classes persisted via SwiftData. The container is configured in `CableApp.swift` and injected into the SwiftUI environment.
|
||||
|
||||
### 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`.
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
`SystemsView` (root list) → `LoadsView` (per-system TabView with 4 tabs: Overview, Components, Batteries, Chargers) → individual editor modals for each entity type.
|
||||
|
||||
### Feature Organization
|
||||
|
||||
Each feature has its own directory under `Cable/`: `Loads/`, `Systems/`, `Batteries/`, `Chargers/`, `Overview/`, `Paywall/`. Models and views for each feature live together.
|
||||
|
||||
## Code Style
|
||||
|
||||
- 4-space indentation, trailing commas on multiline collections, 120-char soft line limit
|
||||
- `UpperCamelCase` for types, `lowerCamelCase` for methods/properties
|
||||
- Test naming: `testScenario_expectedResult`
|
||||
- Commit messages: short imperative subjects under 50 characters
|
||||
|
||||
## Localization
|
||||
|
||||
5 languages: English (base), German, Spanish, French, Dutch. UI strings use `String(localized:)`. Translation files are in `*.lproj/Localizable.strings` and `Localizable.stringsdict`.
|
||||
|
||||
## StoreKit
|
||||
|
||||
Subscription product IDs: `app.voltplan.cable.weekly`, `app.voltplan.cable.yearly`. Pro features are gated via `StoreKitManager.isPro`.
|
||||
@@ -30,6 +30,10 @@
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
3EAA00032F0000000000AA03 /* Aptabase in Frameworks */ = {isa = PBXBuildFile; productRef = 3EAA00022F0000000000AA02 /* Aptabase */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableUITestsScreenshot.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3E5C0BCC2E72C0FD00247EC8 /* Cable.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cable.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -95,6 +99,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3EAA00032F0000000000AA03 /* Aptabase in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -177,6 +182,9 @@
|
||||
3E5C0BCE2E72C0FD00247EC8 /* Cable */,
|
||||
);
|
||||
name = Cable;
|
||||
packageProductDependencies = (
|
||||
3EAA00022F0000000000AA02 /* Aptabase */,
|
||||
);
|
||||
productName = Cable;
|
||||
productReference = 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -263,6 +271,9 @@
|
||||
);
|
||||
mainGroup = 3E5C0BC32E72C0FD00247EC8;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
3EAA00012F0000000000AA01 /* XCRemoteSwiftPackageReference "aptabase-swift" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 3E5C0BCD2E72C0FD00247EC8 /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -715,6 +726,25 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
3EAA00012F0000000000AA01 /* XCRemoteSwiftPackageReference "aptabase-swift" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/aptabase/aptabase-swift.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.3.4;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
3EAA00022F0000000000AA02 /* Aptabase */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 3EAA00012F0000000000AA01 /* XCRemoteSwiftPackageReference "aptabase-swift" */;
|
||||
productName = Aptabase;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 3E5C0BC42E72C0FD00247EC8 /* Project object */;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
enum AmazonAffiliate {
|
||||
private static let fallbackDomain = "www.amazon.com"
|
||||
private static let fallbackTag: String? = nil
|
||||
private static let fallbackTag: String? = "voltplan-20"
|
||||
|
||||
private static let domainsByCountry: [String: String] = [
|
||||
"US": "www.amazon.com",
|
||||
@@ -26,6 +26,10 @@ enum AmazonAffiliate {
|
||||
private static let tagsByCountry: [String: String] = [
|
||||
"US": "voltplan-20",
|
||||
"DE": "voltplan-21",
|
||||
"AU": "voltplan-22",
|
||||
"GB": "voltplan00-21",
|
||||
"FR": "voltplan0f-21",
|
||||
"CA": "voltplan01-20"
|
||||
]
|
||||
|
||||
private static let countryAliases: [String: String] = [
|
||||
|
||||
@@ -8,11 +8,15 @@
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Aptabase
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
AnalyticsTracker.configure()
|
||||
NSLog("Launched")
|
||||
Aptabase.shared.initialize(
|
||||
appKey: "A-SH-4260269603",
|
||||
with: InitOptions(host: "https://apta.yuzuhub.com")
|
||||
)
|
||||
AnalyticsTracker.log("App Launched")
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -21,6 +25,18 @@ enum AnalyticsTracker {
|
||||
static func configure() {}
|
||||
|
||||
static func log(_ event: String, properties: [String: Any] = [:]) {
|
||||
var converted: [String: Any] = [:]
|
||||
for (key, value) in properties {
|
||||
switch value {
|
||||
case let s as String: converted[key] = s
|
||||
case let i as Int: converted[key] = i
|
||||
case let d as Double: converted[key] = d
|
||||
case let f as Float: converted[key] = f
|
||||
case let b as Bool: converted[key] = b
|
||||
default: converted[key] = "\(value)"
|
||||
}
|
||||
}
|
||||
Aptabase.shared.trackEvent(event, with: converted)
|
||||
#if DEBUG
|
||||
if properties.isEmpty {
|
||||
NSLog("Analytics: %@", event)
|
||||
|
||||
@@ -83,6 +83,12 @@
|
||||
"bom.navigation.title" = "Bill of Materials";
|
||||
"bom.navigation.title.system" = "BOM – %@";
|
||||
"bom.size.unknown" = "Size TBD";
|
||||
"bom.search.battery" = "%dAh %dV %@ battery";
|
||||
"bom.search.cable.black" = "%@ black battery cable";
|
||||
"bom.search.cable.red" = "%@ red battery cable";
|
||||
"bom.search.device.fallback" = "DC device %.0fW %.0fV";
|
||||
"bom.search.fuse" = "inline fuse holder %dA";
|
||||
"bom.search.terminals" = "%@ cable shoes";
|
||||
"bom.terminals.detail" = "Ring or spade terminals sized for %@ wiring";
|
||||
"bom.empty.message" = "No components saved in this system yet.";
|
||||
"bom.export.pdf.button" = "Export PDF";
|
||||
|
||||
@@ -5,12 +5,9 @@ struct BatteryEditorView: View {
|
||||
@State private var configuration: BatteryConfiguration
|
||||
@State private var temperatureEditingField: TemperatureEditingField?
|
||||
@State private var isAdvancedExpanded = false
|
||||
@State private var showingProUpsell = false
|
||||
@State private var minimumTemperatureInput: String = ""
|
||||
@State private var maximumTemperatureInput: String = ""
|
||||
@State private var showingAppearanceEditor = false
|
||||
@EnvironmentObject private var storeKitManager: StoreKitManager
|
||||
@State private var hasActiveProSubscription = false
|
||||
let onSave: (BatteryConfiguration) -> Void
|
||||
|
||||
private enum TemperatureEditingField {
|
||||
@@ -529,15 +526,6 @@ struct BatteryEditorView: View {
|
||||
)
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingProUpsell) {
|
||||
CableProPaywallView(isPresented: $showingProUpsell)
|
||||
}
|
||||
.task {
|
||||
hasActiveProSubscription = storeKitManager.isProUnlocked
|
||||
}
|
||||
.onReceive(storeKitManager.$status) { _ in
|
||||
hasActiveProSubscription = storeKitManager.isProUnlocked
|
||||
}
|
||||
.alert(
|
||||
NSLocalizedString(
|
||||
"battery.editor.alert.minimum_temperature.title",
|
||||
@@ -851,9 +839,7 @@ struct BatteryEditorView: View {
|
||||
}
|
||||
|
||||
private var advancedSection: some View {
|
||||
let advancedEnabled = unitSettings.isProUnlocked || hasActiveProSubscription
|
||||
|
||||
return Section {
|
||||
Section {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isAdvancedExpanded.toggle()
|
||||
@@ -870,7 +856,6 @@ struct BatteryEditorView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.opacity(advancedEnabled ? 1 : 0.5)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -878,14 +863,7 @@ struct BatteryEditorView: View {
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
if isAdvancedExpanded {
|
||||
if !advancedEnabled {
|
||||
upgradeToProCTA
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color(.systemBackground))
|
||||
}
|
||||
advancedControls
|
||||
.opacity(advancedEnabled ? 1 : 0.35)
|
||||
.allowsHitTesting(advancedEnabled)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color(.systemBackground))
|
||||
@@ -1000,18 +978,6 @@ struct BatteryEditorView: View {
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
private var upgradeToProCTA: some View {
|
||||
Button {
|
||||
showingProUpsell = true
|
||||
} label: {
|
||||
Text("Get Cable PRO")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
private var temperatureRangeRow: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(temperatureRangeTitle)
|
||||
|
||||
@@ -16,6 +16,8 @@ class SavedBattery {
|
||||
var iconName: String = "battery.100"
|
||||
var colorName: String = "blue"
|
||||
var system: ElectricalSystem?
|
||||
var affiliateURLString: String?
|
||||
var affiliateCountryCode: String?
|
||||
var bomCompletedItemIDs: [String] = []
|
||||
var timestamp: Date
|
||||
|
||||
@@ -33,6 +35,8 @@ class SavedBattery {
|
||||
iconName: String = "battery.100",
|
||||
colorName: String = "blue",
|
||||
system: ElectricalSystem? = nil,
|
||||
affiliateURLString: String? = nil,
|
||||
affiliateCountryCode: String? = nil,
|
||||
bomCompletedItemIDs: [String] = [],
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
@@ -49,6 +53,8 @@ class SavedBattery {
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
self.system = system
|
||||
self.affiliateURLString = affiliateURLString
|
||||
self.affiliateCountryCode = affiliateCountryCode
|
||||
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ import SwiftData
|
||||
@main
|
||||
struct CableApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@StateObject private var unitSettings: UnitSystemSettings
|
||||
@StateObject private var storeKitManager: StoreKitManager
|
||||
@StateObject private var unitSettings = UnitSystemSettings()
|
||||
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
do {
|
||||
@@ -33,9 +32,6 @@ struct CableApp: App {
|
||||
}()
|
||||
|
||||
init() {
|
||||
let unitSettings = UnitSystemSettings()
|
||||
_unitSettings = StateObject(wrappedValue: unitSettings)
|
||||
_storeKitManager = StateObject(wrappedValue: StoreKitManager(unitSettings: unitSettings))
|
||||
#if DEBUG
|
||||
UITestSampleData.handleLaunchArguments(container: sharedModelContainer)
|
||||
#endif
|
||||
@@ -45,7 +41,6 @@ struct CableApp: App {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(unitSettings)
|
||||
.environmentObject(storeKitManager)
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
inputVoltage: Double = 230.0,
|
||||
inputVoltage: Double = LocaleDefaults.mainsVoltage,
|
||||
outputVoltage: Double = 14.2,
|
||||
maxCurrentAmps: Double = 30.0,
|
||||
maxPowerWatts: Double = 0.0,
|
||||
|
||||
@@ -22,7 +22,7 @@ final class SavedCharger {
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
inputVoltage: Double = 230.0,
|
||||
inputVoltage: Double = LocaleDefaults.mainsVoltage,
|
||||
outputVoltage: Double = 14.2,
|
||||
maxCurrentAmps: Double = 30.0,
|
||||
maxPowerWatts: Double = 0.0,
|
||||
|
||||
@@ -25,12 +25,9 @@ struct CalculatorView: View {
|
||||
@State private var dutyCycleInput: String = ""
|
||||
@State private var usageHoursInput: String = ""
|
||||
@State private var showingLoadEditor = false
|
||||
@State private var showingProUpsell = false
|
||||
@State private var presentedAffiliateLink: AffiliateLinkInfo?
|
||||
@State private var completedItemIDs: Set<String>
|
||||
@State private var isAdvancedExpanded = false
|
||||
@EnvironmentObject private var storeKitManager: StoreKitManager
|
||||
@State private var hasActiveProSubscription = false
|
||||
|
||||
let savedLoad: SavedLoad?
|
||||
|
||||
@@ -80,12 +77,6 @@ struct CalculatorView: View {
|
||||
navigationWrapped(mainLayout)
|
||||
)
|
||||
)
|
||||
.task {
|
||||
hasActiveProSubscription = storeKitManager.isProUnlocked
|
||||
}
|
||||
.onReceive(storeKitManager.$status) { _ in
|
||||
hasActiveProSubscription = storeKitManager.isProUnlocked
|
||||
}
|
||||
}
|
||||
|
||||
private func attachAlerts<V: View>(_ view: V) -> some View {
|
||||
@@ -355,9 +346,6 @@ struct CalculatorView: View {
|
||||
.sheet(isPresented: $showingLibrary, content: librarySheet)
|
||||
.sheet(isPresented: $showingLoadEditor, content: loadEditorSheet)
|
||||
.sheet(item: $presentedAffiliateLink, content: billOfMaterialsSheet(info:))
|
||||
.sheet(isPresented: $showingProUpsell) {
|
||||
CableProPaywallView(isPresented: $showingProUpsell)
|
||||
}
|
||||
.onAppear {
|
||||
if let savedLoad = savedLoad {
|
||||
loadConfiguration(from: savedLoad)
|
||||
@@ -478,23 +466,23 @@ struct CalculatorView: View {
|
||||
savedLoad?.remoteIconURLString
|
||||
}
|
||||
|
||||
private var affiliateLinkInfo: AffiliateLinkInfo? {
|
||||
guard let savedLoad else { return nil }
|
||||
|
||||
private var affiliateLinkInfo: AffiliateLinkInfo {
|
||||
let affiliateURL: URL?
|
||||
if let urlString = savedLoad.affiliateURLString,
|
||||
if let urlString = savedLoad?.affiliateURLString,
|
||||
let parsedURL = URL(string: urlString) {
|
||||
affiliateURL = parsedURL
|
||||
} else {
|
||||
affiliateURL = nil
|
||||
}
|
||||
|
||||
let rawCountryCode = savedLoad.affiliateCountryCode ?? Locale.current.region?.identifier
|
||||
let rawCountryCode = savedLoad?.affiliateCountryCode ?? Locale.current.region?.identifier
|
||||
let countryCode = rawCountryCode?.uppercased()
|
||||
let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 }
|
||||
|
||||
let buttonTitle = String(localized: "affiliate.button.review_parts", comment: "Button title to review a bill of materials before shopping")
|
||||
let identifier = "bom-\(savedLoad.name)-\(savedLoad.timestamp.timeIntervalSince1970)"
|
||||
let name = savedLoad?.name ?? calculator.loadName
|
||||
let timestamp = savedLoad?.timestamp ?? Date(timeIntervalSince1970: 0)
|
||||
let identifier = "bom-\(name)-\(timestamp.timeIntervalSince1970)"
|
||||
|
||||
return AffiliateLinkInfo(
|
||||
id: identifier,
|
||||
@@ -753,12 +741,10 @@ struct CalculatorView: View {
|
||||
List {
|
||||
slidersSection
|
||||
advancedSettingsSection
|
||||
if let info = affiliateLinkInfo {
|
||||
affiliateLinkSection(info: info)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
|
||||
}
|
||||
affiliateLinkSection(info: affiliateLinkInfo)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollIndicators(.hidden)
|
||||
@@ -770,6 +756,10 @@ struct CalculatorView: View {
|
||||
private func affiliateLinkSection(info: AffiliateLinkInfo) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Button {
|
||||
AnalyticsTracker.log("Review Parts Tapped", properties: [
|
||||
"load": info.id,
|
||||
"has_affiliate": info.affiliateURL != nil,
|
||||
])
|
||||
presentedAffiliateLink = info
|
||||
} label: {
|
||||
Label(info.buttonTitle, systemImage: "cart")
|
||||
@@ -846,12 +836,17 @@ struct CalculatorView: View {
|
||||
cableGaugeQuery = String(format: "%.1f mm2", crossSectionValue)
|
||||
}
|
||||
|
||||
let redCableQuery = "\(cableGaugeQuery) red battery cable"
|
||||
let blackCableQuery = "\(cableGaugeQuery) black battery cable"
|
||||
let fuseQuery = "inline fuse holder \(fuseRating)A"
|
||||
let terminalQuery = "\(cableGaugeQuery) cable shoes"
|
||||
let redCableSearchFormat = NSLocalizedString("bom.search.cable.red", comment: "Amazon search query for 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 blackCableQuery = String(format: blackCableSearchFormat, cableGaugeQuery)
|
||||
let fuseSearchFormat = NSLocalizedString("bom.search.fuse", comment: "Amazon search query for inline fuse holder")
|
||||
let fuseQuery = String(format: fuseSearchFormat, fuseRating)
|
||||
let terminalSearchFormat = NSLocalizedString("bom.search.terminals", comment: "Amazon search query for 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 deviceQueryBase = calculator.loadName.isEmpty
|
||||
? String(format: "DC device %.0fW %.0fV", calculator.calculatedPower, calculator.voltage)
|
||||
? String(format: deviceFallbackFormat, calculator.calculatedPower, calculator.voltage)
|
||||
: calculator.loadName
|
||||
|
||||
var items: [BOMItem] = []
|
||||
@@ -993,10 +988,6 @@ struct CalculatorView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var advancedFeaturesEnabled: Bool {
|
||||
unitSettings.isProUnlocked || hasActiveProSubscription
|
||||
}
|
||||
|
||||
private var slidersSection: some View {
|
||||
Section {
|
||||
voltageSlider
|
||||
@@ -1012,9 +1003,7 @@ struct CalculatorView: View {
|
||||
}
|
||||
|
||||
private var advancedSettingsSection: some View {
|
||||
let advancedEnabled = advancedFeaturesEnabled
|
||||
|
||||
return Section {
|
||||
Section {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isAdvancedExpanded.toggle()
|
||||
@@ -1031,7 +1020,6 @@ struct CalculatorView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.opacity(advancedEnabled ? 1 : 0.5)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -1039,33 +1027,22 @@ struct CalculatorView: View {
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
if isAdvancedExpanded {
|
||||
if !advancedEnabled {
|
||||
upgradeToProCTA
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color(.systemBackground))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
dutyCycleSlider
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color(.systemBackground))
|
||||
.opacity(advancedEnabled ? 1 : 0.35)
|
||||
.allowsHitTesting(advancedEnabled)
|
||||
Text(dutyCycleHelperText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(advancedEnabled ? 1 : 0.35)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
usageHoursSlider
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color(.systemBackground))
|
||||
.opacity(advancedEnabled ? 1 : 0.35)
|
||||
.allowsHitTesting(advancedEnabled)
|
||||
Text(usageHoursHelperText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(advancedEnabled ? 1 : 0.35)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1074,18 +1051,6 @@ struct CalculatorView: View {
|
||||
.animation(.easeInOut(duration: 0.2), value: isAdvancedExpanded)
|
||||
}
|
||||
|
||||
private var upgradeToProCTA: some View {
|
||||
Button {
|
||||
showingProUpsell = true
|
||||
} label: {
|
||||
Text("Get Cable PRO")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
private var voltageSliderRange: ClosedRange<Double> {
|
||||
let upperBound = max(48, calculator.voltage)
|
||||
return 0...upperBound
|
||||
@@ -1536,6 +1501,14 @@ private struct BillOfMaterialsView: View {
|
||||
return
|
||||
}
|
||||
if let destinationURL {
|
||||
let isAffiliate: Bool
|
||||
if case .affiliate = item.destination { isAffiliate = true } else { isAffiliate = false }
|
||||
AnalyticsTracker.log("BOM Item Tapped", properties: [
|
||||
"item": item.title,
|
||||
"is_affiliate": isAffiliate,
|
||||
"domain": destinationURL.host ?? "unknown",
|
||||
"load": info.id,
|
||||
])
|
||||
openURL(destinationURL)
|
||||
}
|
||||
completedItemIDs.insert(item.id)
|
||||
|
||||
@@ -283,6 +283,10 @@ struct LoadsView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedComponentTab) { _, newValue in
|
||||
AnalyticsTracker.log("Tab Changed", properties: [
|
||||
"tab": "\(newValue)",
|
||||
"system": system.name,
|
||||
])
|
||||
if newValue == .chargers || newValue == .overview {
|
||||
editMode = .inactive
|
||||
}
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
@MainActor
|
||||
final class CableProPaywallViewModel: ObservableObject {
|
||||
enum LoadingState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@Published private(set) var products: [Product] = []
|
||||
@Published private(set) var state: LoadingState = .idle
|
||||
@Published private(set) var purchasingProductID: String?
|
||||
@Published private(set) var isRestoring = false
|
||||
@Published private(set) var purchasedProductIDs: Set<String> = []
|
||||
@Published var alert: PaywallAlert?
|
||||
|
||||
private let productIdentifiers: [String]
|
||||
|
||||
init(productIdentifiers: [String]) {
|
||||
self.productIdentifiers = productIdentifiers
|
||||
Task {
|
||||
await updateCurrentEntitlements()
|
||||
}
|
||||
}
|
||||
|
||||
func loadProducts(force: Bool = false) async {
|
||||
if state == .loading { return }
|
||||
if !force, case .loaded = state { return }
|
||||
|
||||
guard !productIdentifiers.isEmpty else {
|
||||
products = []
|
||||
state = .loaded
|
||||
return
|
||||
}
|
||||
|
||||
state = .loading
|
||||
do {
|
||||
let fetched = try await Product.products(for: productIdentifiers)
|
||||
products = fetched.sorted { productSortKey(lhs: $0, rhs: $1) }
|
||||
state = .loaded
|
||||
await updateCurrentEntitlements()
|
||||
} catch {
|
||||
state = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func productSortKey(lhs: Product, rhs: Product) -> Bool {
|
||||
sortIndex(for: lhs) < sortIndex(for: rhs)
|
||||
}
|
||||
|
||||
private func sortIndex(for product: Product) -> Int {
|
||||
guard let period = product.subscription?.subscriptionPeriod else { return Int.max }
|
||||
switch period.unit {
|
||||
case .day: return 0
|
||||
case .week: return 1
|
||||
case .month: return 2
|
||||
case .year: return 3
|
||||
@unknown default: return 10
|
||||
}
|
||||
}
|
||||
|
||||
func purchase(_ product: Product) async {
|
||||
guard purchasingProductID == nil else { return }
|
||||
|
||||
purchasingProductID = product.id
|
||||
defer { purchasingProductID = nil }
|
||||
|
||||
do {
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try verify(verification)
|
||||
purchasedProductIDs.insert(transaction.productID)
|
||||
alert = PaywallAlert(kind: .success, message: localizedString("cable.pro.alert.success.body", defaultValue: "Thanks for supporting Cable PRO!"))
|
||||
await transaction.finish()
|
||||
await updateCurrentEntitlements()
|
||||
case .userCancelled:
|
||||
break
|
||||
case .pending:
|
||||
alert = PaywallAlert(kind: .pending, message: localizedString("cable.pro.alert.pending.body", defaultValue: "Your purchase is awaiting approval."))
|
||||
@unknown default:
|
||||
alert = PaywallAlert(kind: .error, message: localizedString("cable.pro.alert.error.generic", defaultValue: "Something went wrong. Please try again."))
|
||||
}
|
||||
} catch {
|
||||
alert = PaywallAlert(kind: .error, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func restorePurchases() async {
|
||||
guard !isRestoring else { return }
|
||||
isRestoring = true
|
||||
defer { isRestoring = false }
|
||||
|
||||
do {
|
||||
try await AppStore.sync()
|
||||
await updateCurrentEntitlements()
|
||||
alert = PaywallAlert(kind: .restored, message: localizedString("cable.pro.alert.restored.body", defaultValue: "Your purchases are available again."))
|
||||
} catch {
|
||||
alert = PaywallAlert(kind: .error, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func verify<T>(_ result: VerificationResult<T>) throws -> T {
|
||||
switch result {
|
||||
case .verified(let signed):
|
||||
return signed
|
||||
case .unverified(_, let error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCurrentEntitlements() async {
|
||||
var unlocked: Set<String> = []
|
||||
|
||||
for await result in Transaction.currentEntitlements {
|
||||
switch result {
|
||||
case .verified(let transaction):
|
||||
if productIdentifiers.contains(transaction.productID) {
|
||||
unlocked.insert(transaction.productID)
|
||||
}
|
||||
case .unverified:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
purchasedProductIDs = unlocked
|
||||
}
|
||||
}
|
||||
|
||||
struct CableProPaywallView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var isPresented: Bool
|
||||
@EnvironmentObject private var unitSettings: UnitSystemSettings
|
||||
@EnvironmentObject private var storeKitManager: StoreKitManager
|
||||
|
||||
@StateObject private var viewModel: CableProPaywallViewModel
|
||||
@State private var alertInfo: PaywallAlert?
|
||||
|
||||
private static let defaultProductIds = StoreKitManager.subscriptionProductIDs
|
||||
|
||||
init(isPresented: Binding<Bool>, productIdentifiers: [String] = CableProPaywallView.defaultProductIds) {
|
||||
_isPresented = isPresented
|
||||
_viewModel = StateObject(wrappedValue: CableProPaywallViewModel(productIdentifiers: productIdentifiers))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 24) {
|
||||
header
|
||||
featureList
|
||||
plansSection
|
||||
footer
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 28)
|
||||
.padding(.bottom, 16)
|
||||
.navigationTitle("Cable PRO")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadProducts(force: true)
|
||||
await storeKitManager.refreshEntitlements()
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.loadProducts(force: true)
|
||||
await storeKitManager.refreshEntitlements()
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.alert) { newValue in
|
||||
alertInfo = newValue
|
||||
}
|
||||
.alert(item: $alertInfo) { alert in
|
||||
Alert(
|
||||
title: Text(alert.title),
|
||||
message: Text(alert.messageText),
|
||||
dismissButton: .default(Text("OK")) {
|
||||
viewModel.alert = nil
|
||||
alertInfo = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
.onChange(of: viewModel.purchasedProductIDs) { newValue in
|
||||
Task { await storeKitManager.refreshEntitlements() }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(localizedString("cable.pro.paywall.title", defaultValue: "Unlock Cable PRO"))
|
||||
.font(.largeTitle.bold())
|
||||
Text(localizedString("cable.pro.paywall.subtitle", defaultValue: "Cable PRO enables more configuration options for loads, batteries and chargers."))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var featureList: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
paywallFeature(text: localizedString("cable.pro.feature.dutyCycle", defaultValue: "Duty-cycle aware cable calculators"), icon: "bolt.fill")
|
||||
paywallFeature(text: localizedString("cable.pro.feature.batteryCapacity", defaultValue: "Configure usable battery capacity"), icon: "list.clipboard")
|
||||
paywallFeature(text: localizedString("cable.pro.feature.usageBased", defaultValue: "Usage based calculations"), icon: "sparkles")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func paywallFeature(text: String, icon: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.accentColor.opacity(0.12))
|
||||
)
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var plansSection: some View {
|
||||
switch viewModel.state {
|
||||
case .idle, .loading:
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
.frame(height: 140)
|
||||
.overlay(ProgressView())
|
||||
.frame(maxWidth: .infinity)
|
||||
case .failed(let message):
|
||||
VStack(spacing: 12) {
|
||||
Text("We couldn't load Cable PRO at the moment.")
|
||||
.font(.headline)
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(action: { Task { await viewModel.loadProducts(force: true) } }) {
|
||||
Text("Try Again")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
|
||||
case .loaded:
|
||||
if viewModel.products.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Text("No plans are currently available.")
|
||||
.font(.headline)
|
||||
Text("Check back soon—Cable PRO launches in your region shortly.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
let hasActiveSubscription = !viewModel.purchasedProductIDs.isEmpty
|
||||
|
||||
ForEach(viewModel.products) { product in
|
||||
PlanCard(
|
||||
product: product,
|
||||
isProcessing: viewModel.purchasingProductID == product.id,
|
||||
isPurchased: viewModel.purchasedProductIDs.contains(product.id),
|
||||
isDisabled: hasActiveSubscription && !viewModel.purchasedProductIDs.contains(product.id)
|
||||
) {
|
||||
Task {
|
||||
await viewModel.purchase(product)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.restorePurchases()
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isRestoring {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text(localizedString("cable.pro.restore.button", defaultValue: "Restore Purchases"))
|
||||
.font(.footnote.weight(.semibold))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.padding(.top, 8)
|
||||
.disabled(viewModel.isRestoring)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
if let termsURL = localizedURL(forKey: "cable.pro.terms.url") {
|
||||
Link(localizedString("cable.pro.terms.label", defaultValue: "Terms"), destination: termsURL)
|
||||
}
|
||||
if let privacyURL = localizedURL(forKey: "cable.pro.privacy.url") {
|
||||
Link(localizedString("cable.pro.privacy.label", defaultValue: "Privacy"), destination: privacyURL)
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func localizedURL(forKey key: String) -> URL? {
|
||||
let raw = localizedString(key, defaultValue: "")
|
||||
guard let url = URL(string: raw), !raw.isEmpty else { return nil }
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
private func localizedString(_ key: String, defaultValue: String) -> String {
|
||||
NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "")
|
||||
}
|
||||
|
||||
private func localizedDurationString(for period: Product.SubscriptionPeriod) -> String {
|
||||
let locale = Locale.autoupdatingCurrent
|
||||
let number = localizedNumber(period.value, locale: locale)
|
||||
|
||||
let unitBase: String
|
||||
switch period.unit {
|
||||
case .day: unitBase = "day"
|
||||
case .week: unitBase = "week"
|
||||
case .month: unitBase = "month"
|
||||
case .year: unitBase = "year"
|
||||
@unknown default: unitBase = "day"
|
||||
}
|
||||
|
||||
if period.value == 1 {
|
||||
let key = "cable.pro.duration.\(unitBase).singular"
|
||||
return localizedString(key, defaultValue: singularDurationFallback(for: unitBase))
|
||||
} else {
|
||||
let key = "cable.pro.duration.\(unitBase).plural"
|
||||
let template = localizedString(key, defaultValue: pluralDurationFallback(for: unitBase))
|
||||
return String(format: template, number)
|
||||
}
|
||||
}
|
||||
|
||||
private func localizedNumber(_ value: Int, locale: Locale) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.locale = locale
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter.string(from: NSNumber(value: value)) ?? String(value)
|
||||
}
|
||||
|
||||
private func singularDurationFallback(for unit: String) -> String {
|
||||
switch unit {
|
||||
case "day": return "every day"
|
||||
case "week": return "every week"
|
||||
case "month": return "every month"
|
||||
case "year": return "every year"
|
||||
default: return "every day"
|
||||
}
|
||||
}
|
||||
|
||||
private func pluralDurationFallback(for unit: String) -> String {
|
||||
switch unit {
|
||||
case "day": return "every %@ days"
|
||||
case "week": return "every %@ weeks"
|
||||
case "month": return "every %@ months"
|
||||
case "year": return "every %@ years"
|
||||
default: return "every %@ days"
|
||||
}
|
||||
}
|
||||
|
||||
private func trialDurationString(for period: Product.SubscriptionPeriod) -> String {
|
||||
let locale = Locale.autoupdatingCurrent
|
||||
let number = localizedNumber(period.value, locale: locale)
|
||||
|
||||
let unitBase: String
|
||||
switch period.unit {
|
||||
case .day: unitBase = "day"
|
||||
case .week: unitBase = "week"
|
||||
case .month: unitBase = "month"
|
||||
case .year: unitBase = "year"
|
||||
@unknown default: unitBase = "day"
|
||||
}
|
||||
|
||||
let key = "cable.pro.trial.duration.\(unitBase).\(period.value == 1 ? "singular" : "plural")"
|
||||
|
||||
let fallbackTemplate: String
|
||||
switch unitBase {
|
||||
case "day": fallbackTemplate = "%@-day"
|
||||
case "week": fallbackTemplate = "%@-week"
|
||||
case "month": fallbackTemplate = "%@-month"
|
||||
case "year": fallbackTemplate = "%@-year"
|
||||
default: fallbackTemplate = "%@-day"
|
||||
}
|
||||
|
||||
let template = localizedString(key, defaultValue: fallbackTemplate)
|
||||
if template.contains("%@") {
|
||||
return String(format: template, number)
|
||||
} else {
|
||||
return template
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlanCard: View {
|
||||
let product: Product
|
||||
let isProcessing: Bool
|
||||
let isPurchased: Bool
|
||||
let isDisabled: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(product.displayName)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(product.displayPrice)
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
if let info = product.subscription {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let trial = trialDescription(for: info) {
|
||||
Text(trial)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.accentColor.opacity(0.15))
|
||||
)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
|
||||
Text(subscriptionDescription(for: info))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: action) {
|
||||
Group {
|
||||
if isProcessing {
|
||||
ProgressView()
|
||||
} else if isPurchased {
|
||||
Label(localizedString("cable.pro.button.unlocked", defaultValue: "Unlocked"), systemImage: "checkmark.circle.fill")
|
||||
.labelStyle(.titleAndIcon)
|
||||
} else {
|
||||
let titleKey = product.subscription?.introductoryOffer != nil ? "cable.pro.button.freeTrial" : "cable.pro.button.unlock"
|
||||
Text(localizedString(titleKey, defaultValue: product.subscription?.introductoryOffer != nil ? "Start Free Trial" : "Unlock Now"))
|
||||
}
|
||||
}
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isProcessing || isPurchased || isDisabled)
|
||||
.opacity((isPurchased || isDisabled) ? 0.6 : 1)
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(isPurchased ? Color.accentColor : Color.clear, lineWidth: isPurchased ? 2 : 0)
|
||||
)
|
||||
}
|
||||
|
||||
private func trialDescription(for info: Product.SubscriptionInfo) -> String? {
|
||||
guard
|
||||
let offer = info.introductoryOffer,
|
||||
offer.paymentMode == .freeTrial
|
||||
else { return nil }
|
||||
|
||||
let duration = trialDurationString(for: offer.period)
|
||||
let template = localizedString("cable.pro.trial.badge", defaultValue: "Includes a %@ free trial")
|
||||
return String(format: template, duration)
|
||||
}
|
||||
|
||||
private func subscriptionDescription(for info: Product.SubscriptionInfo) -> String {
|
||||
let quantity = localizedDurationString(for: info.subscriptionPeriod)
|
||||
|
||||
let templateKey: String
|
||||
if let offer = info.introductoryOffer,
|
||||
offer.paymentMode == .freeTrial {
|
||||
templateKey = "cable.pro.subscription.trialThenRenews"
|
||||
} else {
|
||||
templateKey = "cable.pro.subscription.renews"
|
||||
}
|
||||
let template = localizedString(templateKey, defaultValue: templateKey == "cable.pro.subscription.trialThenRenews" ? "Free trial, then renews every %@." : "Renews every %@.")
|
||||
return String(format: template, quantity)
|
||||
}
|
||||
}
|
||||
|
||||
struct PaywallAlert: Identifiable, Equatable {
|
||||
enum Kind { case success, pending, restored, error }
|
||||
|
||||
let id = UUID()
|
||||
let kind: Kind
|
||||
let message: String
|
||||
|
||||
var title: String {
|
||||
switch kind {
|
||||
case .success:
|
||||
return localizedString("cable.pro.alert.success.title", defaultValue: "Cable PRO Unlocked")
|
||||
case .pending:
|
||||
return localizedString("cable.pro.alert.pending.title", defaultValue: "Purchase Pending")
|
||||
case .restored:
|
||||
return localizedString("cable.pro.alert.restored.title", defaultValue: "Purchases Restored")
|
||||
case .error:
|
||||
return localizedString("cable.pro.alert.error.title", defaultValue: "Purchase Failed")
|
||||
}
|
||||
}
|
||||
|
||||
var messageText: String {
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let unitSettings = UnitSystemSettings()
|
||||
let manager = StoreKitManager(unitSettings: unitSettings)
|
||||
return CableProPaywallView(isPresented: .constant(true))
|
||||
.environmentObject(unitSettings)
|
||||
.environmentObject(manager)
|
||||
}
|
||||
@@ -10,12 +10,9 @@ import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||
@EnvironmentObject private var storeKitManager: StoreKitManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@State private var showingProPaywall = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
@@ -27,9 +24,6 @@ struct SettingsView: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
Section("Cable PRO") {
|
||||
proSectionContent
|
||||
}
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
@@ -72,141 +66,11 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingProPaywall) {
|
||||
CableProPaywallView(isPresented: $showingProPaywall)
|
||||
}
|
||||
.onChange(of: showingProPaywall) { isPresented in
|
||||
if !isPresented {
|
||||
Task { await storeKitManager.refreshEntitlements() }
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task { await storeKitManager.refreshEntitlements() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var proSectionContent: some View {
|
||||
if storeKitManager.isRefreshing && storeKitManager.status == nil {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
} else if let status = storeKitManager.status {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label(status.displayName, systemImage: "checkmark.seal.fill")
|
||||
.font(.headline)
|
||||
|
||||
if let renewalDate = status.renewalDate {
|
||||
Text(renewalText(for: renewalDate))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let trialText = trialMessage(for: status) {
|
||||
Text(trialText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if status.isInGracePeriod {
|
||||
Text(localizedString("settings.pro.grace_period", defaultValue: "We're retrying your last payment; access remains during the grace period."))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let isAutoRenewEnabled = status.isAutoRenewEnabled, !isAutoRenewEnabled {
|
||||
Text(localizedString("settings.pro.autorenew.off", defaultValue: "Auto-renew is off—consider renewing to keep Cable PRO."))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(localizedString("settings.pro.instructions", defaultValue: "Manage or cancel your subscription in the App Store."))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 4)
|
||||
|
||||
Button {
|
||||
openManageSubscriptions()
|
||||
} label: {
|
||||
Text(localizedString("settings.pro.manage.button", defaultValue: "Manage Subscription"))
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(6)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(localizedString("settings.pro.cta.description", defaultValue: "Cable PRO keeps advanced calculations and early tools available."))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
showingProPaywall = true
|
||||
} label: {
|
||||
Text(localizedString("settings.pro.cta.button", defaultValue: "Get Cable PRO"))
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(6)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func renewalText(for date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .none
|
||||
formatter.locale = Locale.autoupdatingCurrent
|
||||
let dateString = formatter.string(from: date)
|
||||
let template = localizedString("settings.pro.renewal.date", defaultValue: "Renews on %@.")
|
||||
return String(format: template, dateString)
|
||||
}
|
||||
|
||||
private func trialMessage(for status: StoreKitManager.SubscriptionStatus) -> String? {
|
||||
guard status.isInTrial, let endDate = status.trialEndDate else { return nil }
|
||||
let days = max(Calendar.autoupdatingCurrent.dateComponents([.day], from: Date(), to: endDate).day ?? 0, 0)
|
||||
if days > 0 {
|
||||
let dayText = localizedDayCount(days)
|
||||
let template = localizedString("settings.pro.trial.remaining", defaultValue: "%@ remaining in free trial.")
|
||||
return String(format: template, dayText)
|
||||
} else {
|
||||
return localizedString("settings.pro.trial.today", defaultValue: "Free trial renews today.")
|
||||
}
|
||||
}
|
||||
|
||||
private func localizedDayCount(_ days: Int) -> String {
|
||||
let number = localizedNumber(days)
|
||||
let key = days == 1 ? "settings.pro.day.one" : "settings.pro.day.other"
|
||||
let template = localizedString(key, defaultValue: days == 1 ? "%@ day" : "%@ days")
|
||||
return String(format: template, number)
|
||||
}
|
||||
|
||||
private func openManageSubscriptions() {
|
||||
guard let url = URL(string: localizedString("settings.pro.manage.url", defaultValue: "https://apps.apple.com/account/subscriptions")) else { return }
|
||||
openURL(url)
|
||||
}
|
||||
|
||||
private func localizedNumber(_ value: Int) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.locale = Locale.autoupdatingCurrent
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter.string(from: NSNumber(value: value)) ?? String(value)
|
||||
}
|
||||
|
||||
private func localizedString(_ key: String, defaultValue: String) -> String {
|
||||
NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Settings (Default)") {
|
||||
let settings = UnitSystemSettings()
|
||||
let manager = StoreKitManager(unitSettings: settings)
|
||||
return SettingsView()
|
||||
.environmentObject(settings)
|
||||
.environmentObject(manager)
|
||||
}
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import Foundation
|
||||
import StoreKit
|
||||
|
||||
@MainActor
|
||||
final class StoreKitManager: ObservableObject {
|
||||
struct SubscriptionStatus: Equatable {
|
||||
let productId: String
|
||||
let displayName: String
|
||||
let renewalDate: Date?
|
||||
let isInTrial: Bool
|
||||
let trialEndDate: Date?
|
||||
let isInGracePeriod: Bool
|
||||
let isAutoRenewEnabled: Bool?
|
||||
}
|
||||
|
||||
nonisolated static let subscriptionProductIDs: [String] = [
|
||||
"app.voltplan.cable.weekly",
|
||||
"app.voltplan.cable.yearly"
|
||||
]
|
||||
|
||||
@Published private(set) var status: SubscriptionStatus?
|
||||
@Published private(set) var isRefreshing = false
|
||||
|
||||
var isProUnlocked: Bool {
|
||||
status != nil
|
||||
}
|
||||
|
||||
private let productIDs: Set<String>
|
||||
private weak var unitSettings: UnitSystemSettings?
|
||||
private var updatesTask: Task<Void, Never>?
|
||||
private var productCache: [String: Product] = [:]
|
||||
|
||||
init(
|
||||
productIDs: [String] = StoreKitManager.subscriptionProductIDs,
|
||||
unitSettings: UnitSystemSettings? = nil
|
||||
) {
|
||||
self.productIDs = Set(productIDs)
|
||||
self.unitSettings = unitSettings
|
||||
|
||||
updatesTask = Task { [weak self] in
|
||||
await self?.observeTransactionUpdates()
|
||||
}
|
||||
|
||||
Task { [weak self] in
|
||||
await self?.finishUnfinishedTransactions()
|
||||
await self?.refreshEntitlements()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
updatesTask?.cancel()
|
||||
}
|
||||
|
||||
func attachUnitSettings(_ settings: UnitSystemSettings) {
|
||||
unitSettings = settings
|
||||
Task { [weak self] in
|
||||
await self?.refreshEntitlements()
|
||||
}
|
||||
}
|
||||
|
||||
func refreshEntitlements() async {
|
||||
guard !isRefreshing else { return }
|
||||
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
|
||||
let resolvedStatus = await loadCurrentStatus()
|
||||
status = resolvedStatus
|
||||
unitSettings?.isProUnlocked = resolvedStatus != nil
|
||||
}
|
||||
|
||||
private func loadCurrentStatus() async -> SubscriptionStatus? {
|
||||
if let entitlementStatus = await statusFromCurrentEntitlements() {
|
||||
return entitlementStatus
|
||||
}
|
||||
|
||||
return await statusFromLatestTransactions()
|
||||
}
|
||||
|
||||
private func statusFromCurrentEntitlements() async -> SubscriptionStatus? {
|
||||
var newestTransaction: StoreKit.Transaction?
|
||||
|
||||
for await result in StoreKit.Transaction.currentEntitlements {
|
||||
guard case .verified(let transaction) = result,
|
||||
productIDs.contains(transaction.productID),
|
||||
transaction.revocationDate == nil,
|
||||
!isExpired(transaction) else { continue }
|
||||
|
||||
if let existing = newestTransaction {
|
||||
let existingExpiration = existing.expirationDate ?? .distantPast
|
||||
let candidateExpiration = transaction.expirationDate ?? .distantPast
|
||||
if candidateExpiration > existingExpiration {
|
||||
newestTransaction = transaction
|
||||
}
|
||||
} else {
|
||||
newestTransaction = transaction
|
||||
}
|
||||
}
|
||||
|
||||
guard let activeTransaction = newestTransaction else { return nil }
|
||||
return await status(for: activeTransaction)
|
||||
}
|
||||
|
||||
private func statusFromLatestTransactions() async -> SubscriptionStatus? {
|
||||
var newestTransaction: StoreKit.Transaction?
|
||||
|
||||
for productID in productIDs {
|
||||
guard let latestResult = await StoreKit.Transaction.latest(for: productID) else { continue }
|
||||
guard case .verified(let transaction) = latestResult,
|
||||
transaction.revocationDate == nil,
|
||||
!isExpired(transaction) else { continue }
|
||||
|
||||
if let existing = newestTransaction {
|
||||
let existingExpiration = existing.expirationDate ?? .distantPast
|
||||
let candidateExpiration = transaction.expirationDate ?? .distantPast
|
||||
if candidateExpiration > existingExpiration {
|
||||
newestTransaction = transaction
|
||||
}
|
||||
} else {
|
||||
newestTransaction = transaction
|
||||
}
|
||||
}
|
||||
|
||||
guard let activeTransaction = newestTransaction else { return nil }
|
||||
return await status(for: activeTransaction)
|
||||
}
|
||||
|
||||
private func observeTransactionUpdates() async {
|
||||
for await result in StoreKit.Transaction.updates {
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
switch result {
|
||||
case .verified(let transaction):
|
||||
await transaction.finish()
|
||||
await refreshEntitlements()
|
||||
case .unverified:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishUnfinishedTransactions() async {
|
||||
for await result in StoreKit.Transaction.unfinished {
|
||||
guard case .verified(let transaction) = result else { continue }
|
||||
await transaction.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private func status(for transaction: StoreKit.Transaction) async -> SubscriptionStatus? {
|
||||
let product = await product(for: transaction.productID)
|
||||
let displayName = product?.displayName ?? transaction.productID
|
||||
|
||||
var isInGracePeriod = false
|
||||
var isAutoRenewEnabled: Bool?
|
||||
var isInTrial = false
|
||||
var trialEndDate: Date?
|
||||
|
||||
if let currentStatus = await transaction.subscriptionStatus {
|
||||
if currentStatus.state == .inGracePeriod {
|
||||
isInGracePeriod = true
|
||||
}
|
||||
|
||||
if case .verified(let renewalInfo) = currentStatus.renewalInfo {
|
||||
isAutoRenewEnabled = renewalInfo.willAutoRenew
|
||||
|
||||
if renewalInfo.gracePeriodExpirationDate != nil {
|
||||
isInGracePeriod = true
|
||||
}
|
||||
|
||||
if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) {
|
||||
if let offer = renewalInfo.offer, offer.type == .introductory {
|
||||
isInTrial = true
|
||||
trialEndDate = transaction.expirationDate
|
||||
}
|
||||
} else {
|
||||
#if compiler(>=5.3)
|
||||
if renewalInfo.offerType == .introductory {
|
||||
isInTrial = true
|
||||
trialEndDate = transaction.expirationDate
|
||||
}
|
||||
#endif
|
||||
}
|
||||
} else if case .verified(let statusTransaction) = currentStatus.transaction {
|
||||
if let offer = statusTransaction.offer, offer.type == .introductory {
|
||||
isInTrial = true
|
||||
trialEndDate = statusTransaction.expirationDate ?? transaction.expirationDate
|
||||
}
|
||||
}
|
||||
} else if let offer = transaction.offer, offer.type == .introductory {
|
||||
isInTrial = true
|
||||
trialEndDate = transaction.expirationDate
|
||||
}
|
||||
|
||||
return SubscriptionStatus(
|
||||
productId: transaction.productID,
|
||||
displayName: displayName,
|
||||
renewalDate: transaction.expirationDate,
|
||||
isInTrial: isInTrial,
|
||||
trialEndDate: trialEndDate,
|
||||
isInGracePeriod: isInGracePeriod,
|
||||
isAutoRenewEnabled: isAutoRenewEnabled
|
||||
)
|
||||
}
|
||||
|
||||
private func isExpired(_ transaction: StoreKit.Transaction) -> Bool {
|
||||
if let expirationDate = transaction.expirationDate {
|
||||
return expirationDate < Date()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func product(for id: String) async -> Product? {
|
||||
if let cached = productCache[id] {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let product = try? await Product.products(for: [id]).first else { return nil }
|
||||
productCache[id] = product
|
||||
return product
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,10 @@ struct SystemBillOfMaterialsView: View {
|
||||
return
|
||||
}
|
||||
|
||||
AnalyticsTracker.log("BOM PDF Exported", properties: [
|
||||
"system": systemName,
|
||||
"item_count": categorySections.reduce(0) { $0 + $1.items.count },
|
||||
])
|
||||
isExportingPDF = true
|
||||
let exporter = SystemBillOfMaterialsPDFExporter()
|
||||
let sections = sectionSnapshots
|
||||
@@ -488,6 +492,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
return
|
||||
}
|
||||
if let destinationURL {
|
||||
trackAffiliateTap(item: item, url: destinationURL)
|
||||
openURL(destinationURL)
|
||||
}
|
||||
setCompletion(true, for: item)
|
||||
@@ -495,6 +500,18 @@ struct SystemBillOfMaterialsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func trackAffiliateTap(item: Item, url: URL) {
|
||||
let isAffiliate: Bool
|
||||
if case .affiliate = item.destination { isAffiliate = true } else { isAffiliate = false }
|
||||
AnalyticsTracker.log("BOM Item Tapped", properties: [
|
||||
"item": item.title,
|
||||
"category": item.category.rawValue,
|
||||
"is_affiliate": isAffiliate,
|
||||
"domain": url.host ?? "unknown",
|
||||
"system": systemName,
|
||||
])
|
||||
}
|
||||
|
||||
private func shouldShowDetail(for item: Item) -> Bool {
|
||||
!item.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
@@ -640,14 +657,19 @@ struct SystemBillOfMaterialsView: View {
|
||||
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 deviceQuery = load.name.isEmpty
|
||||
? String(format: "DC device %.0fW %.0fV", calculatedPower, load.voltage)
|
||||
? String(format: deviceFallbackFormat, calculatedPower, load.voltage)
|
||||
: load.name
|
||||
|
||||
let redCableQuery = "\(gaugeQuery) red battery cable"
|
||||
let blackCableQuery = "\(gaugeQuery) black battery cable"
|
||||
let fuseQuery = "inline fuse holder \(fuseRating)A"
|
||||
let terminalQuery = "\(gaugeQuery) cable shoes pack of \(terminalCount)"
|
||||
let redCableSearchFormat = NSLocalizedString("bom.search.cable.red", comment: "Amazon search query for 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 blackCableQuery = String(format: blackCableSearchFormat, gaugeQuery)
|
||||
let fuseSearchFormat = NSLocalizedString("bom.search.fuse", comment: "Amazon search query for inline fuse holder")
|
||||
let fuseQuery = String(format: fuseSearchFormat, fuseRating)
|
||||
let terminalSearchFormat = NSLocalizedString("bom.search.terminals", comment: "Amazon search query for cable shoes")
|
||||
let terminalQuery = String(format: terminalSearchFormat, gaugeQuery)
|
||||
|
||||
let componentStorageKey = Self.storageKey(for: component, itemID: "component")
|
||||
let redCableStorageKey = Self.storageKey(for: component, itemID: "cable-red")
|
||||
@@ -762,7 +784,12 @@ struct SystemBillOfMaterialsView: View {
|
||||
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 query = "\(capacityQuery)Ah \(voltageQuery)V \(battery.chemistry.displayName) battery"
|
||||
let batterySearchFormat = NSLocalizedString(
|
||||
"bom.search.battery",
|
||||
comment: "Amazon search query for a 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")
|
||||
|
||||
return [
|
||||
@@ -773,7 +800,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: battery.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : battery.name,
|
||||
detail: detail,
|
||||
iconSystemName: battery.iconName,
|
||||
destination: .amazonSearch(query),
|
||||
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(query),
|
||||
isPrimaryComponent: true,
|
||||
components: [component],
|
||||
category: .batteries,
|
||||
@@ -834,8 +861,8 @@ struct SystemBillOfMaterialsView: View {
|
||||
switch component {
|
||||
case .load(let load):
|
||||
return load.affiliateCountryCode
|
||||
case .battery:
|
||||
return nil
|
||||
case .battery(let battery):
|
||||
return battery.affiliateCountryCode
|
||||
case .charger(let charger):
|
||||
return charger.affiliateCountryCode
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ struct SystemComponentsPersistence {
|
||||
)
|
||||
let charger = SavedCharger(
|
||||
name: chargerName,
|
||||
inputVoltage: 230,
|
||||
inputVoltage: LocaleDefaults.mainsVoltage,
|
||||
outputVoltage: 14.4,
|
||||
maxCurrentAmps: 30,
|
||||
iconName: "bolt.fill",
|
||||
|
||||
@@ -186,7 +186,7 @@ extension UITestSampleData {
|
||||
|
||||
let shoreCharger = SavedCharger(
|
||||
name: String(localized: "sample.charger.shore.name", comment: "Sample data name for a shore power charger"),
|
||||
inputVoltage: 230.0,
|
||||
inputVoltage: LocaleDefaults.mainsVoltage,
|
||||
outputVoltage: 14.4,
|
||||
maxCurrentAmps: 40.0,
|
||||
maxPowerWatts: 600.0,
|
||||
|
||||
@@ -39,22 +39,32 @@ enum UnitSystem: String, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
enum LocaleDefaults {
|
||||
private static let lowVoltageRegions: Set<String> = [
|
||||
"US", "CA", "MX", "JP", "TW", "CO", "VE", "BR"
|
||||
]
|
||||
|
||||
static var mainsVoltage: Double {
|
||||
let region = Locale.current.region?.identifier.uppercased() ?? ""
|
||||
return lowVoltageRegions.contains(region) ? 120.0 : 230.0
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class UnitSystemSettings: ObservableObject {
|
||||
@Published var unitSystem: UnitSystem {
|
||||
didSet {
|
||||
UserDefaults.standard.set(unitSystem.rawValue, forKey: "unitSystem")
|
||||
}
|
||||
}
|
||||
@Published var isProUnlocked: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(isProUnlocked, forKey: "isProUnlocked")
|
||||
AnalyticsTracker.log("Unit System Changed", properties: ["system": unitSystem.rawValue])
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
let savedSystem = UserDefaults.standard.string(forKey: "unitSystem") ?? UnitSystem.metric.rawValue
|
||||
self.unitSystem = UnitSystem(rawValue: savedSystem) ?? .metric
|
||||
self.isProUnlocked = UserDefaults.standard.bool(forKey: "isProUnlocked")
|
||||
if let saved = UserDefaults.standard.string(forKey: "unitSystem"),
|
||||
let system = UnitSystem(rawValue: saved) {
|
||||
self.unitSystem = system
|
||||
} else {
|
||||
self.unitSystem = Locale.current.measurementSystem == .us ? .imperial : .metric
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,12 @@
|
||||
"bom.navigation.title" = "Stückliste";
|
||||
"bom.navigation.title.system" = "Stückliste – %@";
|
||||
"bom.size.unknown" = "Größe offen";
|
||||
"bom.search.battery" = "%dAh %dV %@ Batterie";
|
||||
"bom.search.cable.black" = "%@ schwarzes Batteriekabel";
|
||||
"bom.search.cable.red" = "%@ rotes Batteriekabel";
|
||||
"bom.search.device.fallback" = "DC Gerät %.0fW %.0fV";
|
||||
"bom.search.fuse" = "KFZ Sicherungshalter %dA";
|
||||
"bom.search.terminals" = "%@ Kabelschuhe";
|
||||
"bom.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen";
|
||||
"bom.empty.message" = "Dieses System hat noch keine Komponenten.";
|
||||
"bom.export.pdf.button" = "PDF exportieren";
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
"bom.navigation.title" = "Lista de materiales";
|
||||
"bom.navigation.title.system" = "Lista de materiales – %@";
|
||||
"bom.size.unknown" = "Tamaño por definir";
|
||||
"bom.search.battery" = "%dAh %dV %@ batería";
|
||||
"bom.search.cable.black" = "%@ cable batería negro";
|
||||
"bom.search.cable.red" = "%@ cable batería rojo";
|
||||
"bom.search.device.fallback" = "dispositivo DC %.0fW %.0fV";
|
||||
"bom.search.fuse" = "portafusible en línea %dA";
|
||||
"bom.search.terminals" = "%@ terminales de cable";
|
||||
"bom.terminals.detail" = "Terminales de anillo o de horquilla para cables de %@";
|
||||
"bom.empty.message" = "Todavía no hay componentes guardados en este sistema.";
|
||||
"bom.export.pdf.button" = "Exportar PDF";
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
"bom.navigation.title" = "Liste de matériel";
|
||||
"bom.navigation.title.system" = "Liste de matériel – %@";
|
||||
"bom.size.unknown" = "Taille à déterminer";
|
||||
"bom.search.battery" = "%dAh %dV %@ batterie";
|
||||
"bom.search.cable.black" = "%@ câble batterie noir";
|
||||
"bom.search.cable.red" = "%@ câble batterie rouge";
|
||||
"bom.search.device.fallback" = "appareil DC %.0fW %.0fV";
|
||||
"bom.search.fuse" = "porte-fusible %dA";
|
||||
"bom.search.terminals" = "%@ cosses de câble";
|
||||
"bom.terminals.detail" = "Cosses à œillet ou à fourche adaptées aux câbles de %@";
|
||||
"bom.empty.message" = "Aucun composant enregistré pour ce système pour l’instant.";
|
||||
"bom.export.pdf.button" = "Exporter en PDF";
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
"bom.navigation.title" = "Materiaallijst";
|
||||
"bom.navigation.title.system" = "Materiaallijst – %@";
|
||||
"bom.size.unknown" = "Afmeting nog onbekend";
|
||||
"bom.search.battery" = "%dAh %dV %@ batterij";
|
||||
"bom.search.cable.black" = "%@ zwarte accukabel";
|
||||
"bom.search.cable.red" = "%@ rode accukabel";
|
||||
"bom.search.device.fallback" = "DC apparaat %.0fW %.0fV";
|
||||
"bom.search.fuse" = "inline zekeringhouder %dA";
|
||||
"bom.search.terminals" = "%@ kabelschoenen";
|
||||
"bom.terminals.detail" = "Ring- of vorkklemmen geschikt voor %@-bekabeling";
|
||||
"bom.empty.message" = "Er zijn nog geen componenten voor dit systeem opgeslagen.";
|
||||
"bom.export.pdf.button" = "PDF exporteren";
|
||||
|
||||
Reference in New Issue
Block a user