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:
2026-02-17 23:06:45 +01:00
parent 34e8c0f74b
commit 5a5e8b8fbe
23 changed files with 255 additions and 1034 deletions

63
CLAUDE.md Normal file
View 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`.

View File

@@ -30,6 +30,10 @@
}; };
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXBuildFile section */
3EAA00032F0000000000AA03 /* Aptabase in Frameworks */ = {isa = PBXBuildFile; productRef = 3EAA00022F0000000000AA02 /* Aptabase */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableUITestsScreenshot.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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; }; 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cable.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -95,6 +99,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
3EAA00032F0000000000AA03 /* Aptabase in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -177,6 +182,9 @@
3E5C0BCE2E72C0FD00247EC8 /* Cable */, 3E5C0BCE2E72C0FD00247EC8 /* Cable */,
); );
name = Cable; name = Cable;
packageProductDependencies = (
3EAA00022F0000000000AA02 /* Aptabase */,
);
productName = Cable; productName = Cable;
productReference = 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */; productReference = 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@@ -263,6 +271,9 @@
); );
mainGroup = 3E5C0BC32E72C0FD00247EC8; mainGroup = 3E5C0BC32E72C0FD00247EC8;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
3EAA00012F0000000000AA01 /* XCRemoteSwiftPackageReference "aptabase-swift" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 3E5C0BCD2E72C0FD00247EC8 /* Products */; productRefGroup = 3E5C0BCD2E72C0FD00247EC8 /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -715,6 +726,25 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* 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 */; rootObject = 3E5C0BC42E72C0FD00247EC8 /* Project object */;
} }

View File

@@ -2,7 +2,7 @@ import Foundation
enum AmazonAffiliate { enum AmazonAffiliate {
private static let fallbackDomain = "www.amazon.com" 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] = [ private static let domainsByCountry: [String: String] = [
"US": "www.amazon.com", "US": "www.amazon.com",
@@ -26,6 +26,10 @@ enum AmazonAffiliate {
private static let tagsByCountry: [String: String] = [ private static let tagsByCountry: [String: String] = [
"US": "voltplan-20", "US": "voltplan-20",
"DE": "voltplan-21", "DE": "voltplan-21",
"AU": "voltplan-22",
"GB": "voltplan00-21",
"FR": "voltplan0f-21",
"CA": "voltplan01-20"
] ]
private static let countryAliases: [String: String] = [ private static let countryAliases: [String: String] = [

View File

@@ -8,11 +8,15 @@
import Foundation import Foundation
import UIKit import UIKit
import Aptabase
class AppDelegate: NSObject, UIApplicationDelegate { class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
AnalyticsTracker.configure() Aptabase.shared.initialize(
NSLog("Launched") appKey: "A-SH-4260269603",
with: InitOptions(host: "https://apta.yuzuhub.com")
)
AnalyticsTracker.log("App Launched")
return true return true
} }
} }
@@ -21,6 +25,18 @@ enum AnalyticsTracker {
static func configure() {} static func configure() {}
static func log(_ event: String, properties: [String: Any] = [:]) { 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 DEBUG
if properties.isEmpty { if properties.isEmpty {
NSLog("Analytics: %@", event) NSLog("Analytics: %@", event)

View File

@@ -83,6 +83,12 @@
"bom.navigation.title" = "Bill of Materials"; "bom.navigation.title" = "Bill of Materials";
"bom.navigation.title.system" = "BOM %@"; "bom.navigation.title.system" = "BOM %@";
"bom.size.unknown" = "Size TBD"; "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.terminals.detail" = "Ring or spade terminals sized for %@ wiring";
"bom.empty.message" = "No components saved in this system yet."; "bom.empty.message" = "No components saved in this system yet.";
"bom.export.pdf.button" = "Export PDF"; "bom.export.pdf.button" = "Export PDF";

View File

@@ -5,12 +5,9 @@ struct BatteryEditorView: View {
@State private var configuration: BatteryConfiguration @State private var configuration: BatteryConfiguration
@State private var temperatureEditingField: TemperatureEditingField? @State private var temperatureEditingField: TemperatureEditingField?
@State private var isAdvancedExpanded = false @State private var isAdvancedExpanded = false
@State private var showingProUpsell = false
@State private var minimumTemperatureInput: String = "" @State private var minimumTemperatureInput: String = ""
@State private var maximumTemperatureInput: String = "" @State private var maximumTemperatureInput: String = ""
@State private var showingAppearanceEditor = false @State private var showingAppearanceEditor = false
@EnvironmentObject private var storeKitManager: StoreKitManager
@State private var hasActiveProSubscription = false
let onSave: (BatteryConfiguration) -> Void let onSave: (BatteryConfiguration) -> Void
private enum TemperatureEditingField { 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( .alert(
NSLocalizedString( NSLocalizedString(
"battery.editor.alert.minimum_temperature.title", "battery.editor.alert.minimum_temperature.title",
@@ -851,9 +839,7 @@ struct BatteryEditorView: View {
} }
private var advancedSection: some View { private var advancedSection: some View {
let advancedEnabled = unitSettings.isProUnlocked || hasActiveProSubscription Section {
return Section {
Button { Button {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
isAdvancedExpanded.toggle() isAdvancedExpanded.toggle()
@@ -870,7 +856,6 @@ struct BatteryEditorView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.padding(.vertical, 10) .padding(.vertical, 10)
.opacity(advancedEnabled ? 1 : 0.5)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -878,14 +863,7 @@ struct BatteryEditorView: View {
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
if isAdvancedExpanded { if isAdvancedExpanded {
if !advancedEnabled {
upgradeToProCTA
.listRowSeparator(.hidden)
.listRowBackground(Color(.systemBackground))
}
advancedControls advancedControls
.opacity(advancedEnabled ? 1 : 0.35)
.allowsHitTesting(advancedEnabled)
.transition(.opacity.combined(with: .move(edge: .top))) .transition(.opacity.combined(with: .move(edge: .top)))
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(Color(.systemBackground)) .listRowBackground(Color(.systemBackground))
@@ -1000,18 +978,6 @@ struct BatteryEditorView: View {
.padding(.top, 6) .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 { private var temperatureRangeRow: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text(temperatureRangeTitle) Text(temperatureRangeTitle)

View File

@@ -16,6 +16,8 @@ class SavedBattery {
var iconName: String = "battery.100" var iconName: String = "battery.100"
var colorName: String = "blue" var colorName: String = "blue"
var system: ElectricalSystem? var system: ElectricalSystem?
var affiliateURLString: String?
var affiliateCountryCode: String?
var bomCompletedItemIDs: [String] = [] var bomCompletedItemIDs: [String] = []
var timestamp: Date var timestamp: Date
@@ -33,6 +35,8 @@ class SavedBattery {
iconName: String = "battery.100", iconName: String = "battery.100",
colorName: String = "blue", colorName: String = "blue",
system: ElectricalSystem? = nil, system: ElectricalSystem? = nil,
affiliateURLString: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [], bomCompletedItemIDs: [String] = [],
timestamp: Date = Date() timestamp: Date = Date()
) { ) {
@@ -49,6 +53,8 @@ class SavedBattery {
self.iconName = iconName self.iconName = iconName
self.colorName = colorName self.colorName = colorName
self.system = system self.system = system
self.affiliateURLString = affiliateURLString
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs self.bomCompletedItemIDs = bomCompletedItemIDs
self.timestamp = timestamp self.timestamp = timestamp
} }

View File

@@ -11,16 +11,15 @@ import SwiftData
@main @main
struct CableApp: App { struct CableApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var unitSettings: UnitSystemSettings @StateObject private var unitSettings = UnitSystemSettings()
@StateObject private var storeKitManager: StoreKitManager
var sharedModelContainer: ModelContainer = { var sharedModelContainer: ModelContainer = {
do { do {
// Try the simple approach first // Try the simple approach first
return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self) return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self)
} catch { } catch {
print("Failed to create ModelContainer with simple approach: \(error)") print("Failed to create ModelContainer with simple approach: \(error)")
// Try in-memory as fallback // Try in-memory as fallback
do { do {
let schema = Schema([ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self]) let schema = Schema([ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self])
@@ -33,9 +32,6 @@ struct CableApp: App {
}() }()
init() { init() {
let unitSettings = UnitSystemSettings()
_unitSettings = StateObject(wrappedValue: unitSettings)
_storeKitManager = StateObject(wrappedValue: StoreKitManager(unitSettings: unitSettings))
#if DEBUG #if DEBUG
UITestSampleData.handleLaunchArguments(container: sharedModelContainer) UITestSampleData.handleLaunchArguments(container: sharedModelContainer)
#endif #endif
@@ -45,7 +41,6 @@ struct CableApp: App {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environmentObject(unitSettings) .environmentObject(unitSettings)
.environmentObject(storeKitManager)
} }
.modelContainer(sharedModelContainer) .modelContainer(sharedModelContainer)
} }

View File

@@ -15,7 +15,7 @@ struct ChargerConfiguration: Identifiable, Hashable {
init( init(
id: UUID = UUID(), id: UUID = UUID(),
name: String, name: String,
inputVoltage: Double = 230.0, inputVoltage: Double = LocaleDefaults.mainsVoltage,
outputVoltage: Double = 14.2, outputVoltage: Double = 14.2,
maxCurrentAmps: Double = 30.0, maxCurrentAmps: Double = 30.0,
maxPowerWatts: Double = 0.0, maxPowerWatts: Double = 0.0,

View File

@@ -22,7 +22,7 @@ final class SavedCharger {
init( init(
id: UUID = UUID(), id: UUID = UUID(),
name: String, name: String,
inputVoltage: Double = 230.0, inputVoltage: Double = LocaleDefaults.mainsVoltage,
outputVoltage: Double = 14.2, outputVoltage: Double = 14.2,
maxCurrentAmps: Double = 30.0, maxCurrentAmps: Double = 30.0,
maxPowerWatts: Double = 0.0, maxPowerWatts: Double = 0.0,

View File

@@ -25,12 +25,9 @@ struct CalculatorView: View {
@State private var dutyCycleInput: String = "" @State private var dutyCycleInput: String = ""
@State private var usageHoursInput: String = "" @State private var usageHoursInput: String = ""
@State private var showingLoadEditor = false @State private var showingLoadEditor = false
@State private var showingProUpsell = false
@State private var presentedAffiliateLink: AffiliateLinkInfo? @State private var presentedAffiliateLink: AffiliateLinkInfo?
@State private var completedItemIDs: Set<String> @State private var completedItemIDs: Set<String>
@State private var isAdvancedExpanded = false @State private var isAdvancedExpanded = false
@EnvironmentObject private var storeKitManager: StoreKitManager
@State private var hasActiveProSubscription = false
let savedLoad: SavedLoad? let savedLoad: SavedLoad?
@@ -80,12 +77,6 @@ struct CalculatorView: View {
navigationWrapped(mainLayout) navigationWrapped(mainLayout)
) )
) )
.task {
hasActiveProSubscription = storeKitManager.isProUnlocked
}
.onReceive(storeKitManager.$status) { _ in
hasActiveProSubscription = storeKitManager.isProUnlocked
}
} }
private func attachAlerts<V: View>(_ view: V) -> some View { private func attachAlerts<V: View>(_ view: V) -> some View {
@@ -355,9 +346,6 @@ struct CalculatorView: View {
.sheet(isPresented: $showingLibrary, content: librarySheet) .sheet(isPresented: $showingLibrary, content: librarySheet)
.sheet(isPresented: $showingLoadEditor, content: loadEditorSheet) .sheet(isPresented: $showingLoadEditor, content: loadEditorSheet)
.sheet(item: $presentedAffiliateLink, content: billOfMaterialsSheet(info:)) .sheet(item: $presentedAffiliateLink, content: billOfMaterialsSheet(info:))
.sheet(isPresented: $showingProUpsell) {
CableProPaywallView(isPresented: $showingProUpsell)
}
.onAppear { .onAppear {
if let savedLoad = savedLoad { if let savedLoad = savedLoad {
loadConfiguration(from: savedLoad) loadConfiguration(from: savedLoad)
@@ -478,23 +466,23 @@ struct CalculatorView: View {
savedLoad?.remoteIconURLString savedLoad?.remoteIconURLString
} }
private var affiliateLinkInfo: AffiliateLinkInfo? { private var affiliateLinkInfo: AffiliateLinkInfo {
guard let savedLoad else { return nil }
let affiliateURL: URL? let affiliateURL: URL?
if let urlString = savedLoad.affiliateURLString, if let urlString = savedLoad?.affiliateURLString,
let parsedURL = URL(string: urlString) { let parsedURL = URL(string: urlString) {
affiliateURL = parsedURL affiliateURL = parsedURL
} else { } else {
affiliateURL = nil affiliateURL = nil
} }
let rawCountryCode = savedLoad.affiliateCountryCode ?? Locale.current.region?.identifier let rawCountryCode = savedLoad?.affiliateCountryCode ?? Locale.current.region?.identifier
let countryCode = rawCountryCode?.uppercased() let countryCode = rawCountryCode?.uppercased()
let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 } 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 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( return AffiliateLinkInfo(
id: identifier, id: identifier,
@@ -753,12 +741,10 @@ struct CalculatorView: View {
List { List {
slidersSection slidersSection
advancedSettingsSection advancedSettingsSection
if let info = affiliateLinkInfo { affiliateLinkSection(info: affiliateLinkInfo)
affiliateLinkSection(info: info) .listRowBackground(Color.clear)
.listRowBackground(Color.clear) .listRowSeparator(.hidden)
.listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
}
} }
.listStyle(.plain) .listStyle(.plain)
.scrollIndicators(.hidden) .scrollIndicators(.hidden)
@@ -770,6 +756,10 @@ struct CalculatorView: View {
private func affiliateLinkSection(info: AffiliateLinkInfo) -> some View { private func affiliateLinkSection(info: AffiliateLinkInfo) -> some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Button { Button {
AnalyticsTracker.log("Review Parts Tapped", properties: [
"load": info.id,
"has_affiliate": info.affiliateURL != nil,
])
presentedAffiliateLink = info presentedAffiliateLink = info
} label: { } label: {
Label(info.buttonTitle, systemImage: "cart") Label(info.buttonTitle, systemImage: "cart")
@@ -846,12 +836,17 @@ struct CalculatorView: View {
cableGaugeQuery = String(format: "%.1f mm2", crossSectionValue) cableGaugeQuery = String(format: "%.1f mm2", crossSectionValue)
} }
let redCableQuery = "\(cableGaugeQuery) red battery cable" let redCableSearchFormat = NSLocalizedString("bom.search.cable.red", comment: "Amazon search query for red battery cable")
let blackCableQuery = "\(cableGaugeQuery) black battery cable" let redCableQuery = String(format: redCableSearchFormat, cableGaugeQuery)
let fuseQuery = "inline fuse holder \(fuseRating)A" let blackCableSearchFormat = NSLocalizedString("bom.search.cable.black", comment: "Amazon search query for black battery cable")
let terminalQuery = "\(cableGaugeQuery) cable shoes" 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 let deviceQueryBase = calculator.loadName.isEmpty
? String(format: "DC device %.0fW %.0fV", calculator.calculatedPower, calculator.voltage) ? String(format: deviceFallbackFormat, calculator.calculatedPower, calculator.voltage)
: calculator.loadName : calculator.loadName
var items: [BOMItem] = [] var items: [BOMItem] = []
@@ -993,10 +988,6 @@ struct CalculatorView: View {
) )
} }
private var advancedFeaturesEnabled: Bool {
unitSettings.isProUnlocked || hasActiveProSubscription
}
private var slidersSection: some View { private var slidersSection: some View {
Section { Section {
voltageSlider voltageSlider
@@ -1012,9 +1003,7 @@ struct CalculatorView: View {
} }
private var advancedSettingsSection: some View { private var advancedSettingsSection: some View {
let advancedEnabled = advancedFeaturesEnabled Section {
return Section {
Button { Button {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
isAdvancedExpanded.toggle() isAdvancedExpanded.toggle()
@@ -1031,7 +1020,6 @@ struct CalculatorView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.padding(.vertical, 10) .padding(.vertical, 10)
.opacity(advancedEnabled ? 1 : 0.5)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -1039,33 +1027,22 @@ struct CalculatorView: View {
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
if isAdvancedExpanded { if isAdvancedExpanded {
if !advancedEnabled {
upgradeToProCTA
.listRowSeparator(.hidden)
.listRowBackground(Color(.systemBackground))
}
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
dutyCycleSlider dutyCycleSlider
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(Color(.systemBackground)) .listRowBackground(Color(.systemBackground))
.opacity(advancedEnabled ? 1 : 0.35)
.allowsHitTesting(advancedEnabled)
Text(dutyCycleHelperText) Text(dutyCycleHelperText)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.opacity(advancedEnabled ? 1 : 0.35)
} }
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
usageHoursSlider usageHoursSlider
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(Color(.systemBackground)) .listRowBackground(Color(.systemBackground))
.opacity(advancedEnabled ? 1 : 0.35)
.allowsHitTesting(advancedEnabled)
Text(usageHoursHelperText) Text(usageHoursHelperText)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.opacity(advancedEnabled ? 1 : 0.35)
} }
} }
} }
@@ -1074,18 +1051,6 @@ struct CalculatorView: View {
.animation(.easeInOut(duration: 0.2), value: isAdvancedExpanded) .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> { private var voltageSliderRange: ClosedRange<Double> {
let upperBound = max(48, calculator.voltage) let upperBound = max(48, calculator.voltage)
return 0...upperBound return 0...upperBound
@@ -1536,6 +1501,14 @@ private struct BillOfMaterialsView: View {
return return
} }
if let destinationURL { 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) openURL(destinationURL)
} }
completedItemIDs.insert(item.id) completedItemIDs.insert(item.id)

View File

@@ -283,6 +283,10 @@ struct LoadsView: View {
} }
} }
.onChange(of: selectedComponentTab) { _, newValue in .onChange(of: selectedComponentTab) { _, newValue in
AnalyticsTracker.log("Tab Changed", properties: [
"tab": "\(newValue)",
"system": system.name,
])
if newValue == .chargers || newValue == .overview { if newValue == .chargers || newValue == .overview {
editMode = .inactive editMode = .inactive
} }

View File

@@ -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)
}

View File

@@ -10,12 +10,9 @@ import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject var unitSettings: UnitSystemSettings @EnvironmentObject var unitSettings: UnitSystemSettings
@EnvironmentObject private var storeKitManager: StoreKitManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@State private var showingProPaywall = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
@@ -27,9 +24,6 @@ struct SettingsView: View {
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
} }
Section("Cable PRO") {
proSectionContent
}
Section { Section {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -40,15 +34,15 @@ struct SettingsView: View {
.font(.headline) .font(.headline)
.fontWeight(.semibold) .fontWeight(.semibold)
} }
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("This application provides electrical calculations for educational and estimation purposes only.") Text("This application provides electrical calculations for educational and estimation purposes only.")
.font(.body) .font(.body)
Text("Important:") Text("Important:")
.font(.subheadline) .font(.subheadline)
.fontWeight(.semibold) .fontWeight(.semibold)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("• Always consult qualified electricians for actual installations") Text("• Always consult qualified electricians for actual installations")
Text("• Follow all local electrical codes and regulations") Text("• Follow all local electrical codes and regulations")
@@ -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)") { #Preview("Settings (Default)") {
let settings = UnitSystemSettings() let settings = UnitSystemSettings()
let manager = StoreKitManager(unitSettings: settings)
return SettingsView() return SettingsView()
.environmentObject(settings) .environmentObject(settings)
.environmentObject(manager)
} }

View File

@@ -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
}
}

View File

@@ -390,6 +390,10 @@ struct SystemBillOfMaterialsView: View {
return return
} }
AnalyticsTracker.log("BOM PDF Exported", properties: [
"system": systemName,
"item_count": categorySections.reduce(0) { $0 + $1.items.count },
])
isExportingPDF = true isExportingPDF = true
let exporter = SystemBillOfMaterialsPDFExporter() let exporter = SystemBillOfMaterialsPDFExporter()
let sections = sectionSnapshots let sections = sectionSnapshots
@@ -488,6 +492,7 @@ struct SystemBillOfMaterialsView: View {
return return
} }
if let destinationURL { if let destinationURL {
trackAffiliateTap(item: item, url: destinationURL)
openURL(destinationURL) openURL(destinationURL)
} }
setCompletion(true, for: item) 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 { private func shouldShowDetail(for item: Item) -> Bool {
!item.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !item.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
@@ -640,14 +657,19 @@ struct SystemBillOfMaterialsView: View {
crossSectionLabel.lowercased() crossSectionLabel.lowercased()
) )
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) } let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
let deviceFallbackFormat = NSLocalizedString("bom.search.device.fallback", comment: "Amazon search query fallback for a DC device")
let deviceQuery = load.name.isEmpty let deviceQuery = load.name.isEmpty
? String(format: "DC device %.0fW %.0fV", calculatedPower, load.voltage) ? String(format: deviceFallbackFormat, calculatedPower, load.voltage)
: load.name : load.name
let redCableQuery = "\(gaugeQuery) red battery cable" let redCableSearchFormat = NSLocalizedString("bom.search.cable.red", comment: "Amazon search query for red battery cable")
let blackCableQuery = "\(gaugeQuery) black battery cable" let redCableQuery = String(format: redCableSearchFormat, gaugeQuery)
let fuseQuery = "inline fuse holder \(fuseRating)A" let blackCableSearchFormat = NSLocalizedString("bom.search.cable.black", comment: "Amazon search query for black battery cable")
let terminalQuery = "\(gaugeQuery) cable shoes pack of \(terminalCount)" 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 componentStorageKey = Self.storageKey(for: component, itemID: "component")
let redCableStorageKey = Self.storageKey(for: component, itemID: "cable-red") 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 detail = [capacityLabel, battery.chemistry.displayName, usableLabel].joined(separator: "")
let capacityQuery = max(1, Int(round(battery.capacityAmpHours))) let capacityQuery = max(1, Int(round(battery.capacityAmpHours)))
let voltageQuery = max(1, Int(round(battery.nominalVoltage))) let voltageQuery = max(1, Int(round(battery.nominalVoltage)))
let 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") let storageKey = Self.storageKey(for: component, itemID: "battery")
return [ 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, title: battery.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : battery.name,
detail: detail, detail: detail,
iconSystemName: battery.iconName, iconSystemName: battery.iconName,
destination: .amazonSearch(query), destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(query),
isPrimaryComponent: true, isPrimaryComponent: true,
components: [component], components: [component],
category: .batteries, category: .batteries,
@@ -834,8 +861,8 @@ struct SystemBillOfMaterialsView: View {
switch component { switch component {
case .load(let load): case .load(let load):
return load.affiliateCountryCode return load.affiliateCountryCode
case .battery: case .battery(let battery):
return nil return battery.affiliateCountryCode
case .charger(let charger): case .charger(let charger):
return charger.affiliateCountryCode return charger.affiliateCountryCode
} }

View File

@@ -263,7 +263,7 @@ struct SystemComponentsPersistence {
) )
let charger = SavedCharger( let charger = SavedCharger(
name: chargerName, name: chargerName,
inputVoltage: 230, inputVoltage: LocaleDefaults.mainsVoltage,
outputVoltage: 14.4, outputVoltage: 14.4,
maxCurrentAmps: 30, maxCurrentAmps: 30,
iconName: "bolt.fill", iconName: "bolt.fill",

View File

@@ -186,7 +186,7 @@ extension UITestSampleData {
let shoreCharger = SavedCharger( let shoreCharger = SavedCharger(
name: String(localized: "sample.charger.shore.name", comment: "Sample data name for a shore power charger"), 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, outputVoltage: 14.4,
maxCurrentAmps: 40.0, maxCurrentAmps: 40.0,
maxPowerWatts: 600.0, maxPowerWatts: 600.0,

View File

@@ -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 @MainActor
class UnitSystemSettings: ObservableObject { class UnitSystemSettings: ObservableObject {
@Published var unitSystem: UnitSystem { @Published var unitSystem: UnitSystem {
didSet { didSet {
UserDefaults.standard.set(unitSystem.rawValue, forKey: "unitSystem") UserDefaults.standard.set(unitSystem.rawValue, forKey: "unitSystem")
AnalyticsTracker.log("Unit System Changed", properties: ["system": unitSystem.rawValue])
} }
} }
@Published var isProUnlocked: Bool {
didSet {
UserDefaults.standard.set(isProUnlocked, forKey: "isProUnlocked")
}
}
init() { init() {
let savedSystem = UserDefaults.standard.string(forKey: "unitSystem") ?? UnitSystem.metric.rawValue if let saved = UserDefaults.standard.string(forKey: "unitSystem"),
self.unitSystem = UnitSystem(rawValue: savedSystem) ?? .metric let system = UnitSystem(rawValue: saved) {
self.isProUnlocked = UserDefaults.standard.bool(forKey: "isProUnlocked") self.unitSystem = system
} else {
self.unitSystem = Locale.current.measurementSystem == .us ? .imperial : .metric
}
} }
} }

View File

@@ -143,6 +143,12 @@
"bom.navigation.title" = "Stückliste"; "bom.navigation.title" = "Stückliste";
"bom.navigation.title.system" = "Stückliste %@"; "bom.navigation.title.system" = "Stückliste %@";
"bom.size.unknown" = "Größe offen"; "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.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen";
"bom.empty.message" = "Dieses System hat noch keine Komponenten."; "bom.empty.message" = "Dieses System hat noch keine Komponenten.";
"bom.export.pdf.button" = "PDF exportieren"; "bom.export.pdf.button" = "PDF exportieren";

View File

@@ -13,6 +13,12 @@
"bom.navigation.title" = "Lista de materiales"; "bom.navigation.title" = "Lista de materiales";
"bom.navigation.title.system" = "Lista de materiales %@"; "bom.navigation.title.system" = "Lista de materiales %@";
"bom.size.unknown" = "Tamaño por definir"; "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.terminals.detail" = "Terminales de anillo o de horquilla para cables de %@";
"bom.empty.message" = "Todavía no hay componentes guardados en este sistema."; "bom.empty.message" = "Todavía no hay componentes guardados en este sistema.";
"bom.export.pdf.button" = "Exportar PDF"; "bom.export.pdf.button" = "Exportar PDF";

View File

@@ -13,6 +13,12 @@
"bom.navigation.title" = "Liste de matériel"; "bom.navigation.title" = "Liste de matériel";
"bom.navigation.title.system" = "Liste de matériel %@"; "bom.navigation.title.system" = "Liste de matériel %@";
"bom.size.unknown" = "Taille à déterminer"; "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.terminals.detail" = "Cosses à œillet ou à fourche adaptées aux câbles de %@";
"bom.empty.message" = "Aucun composant enregistré pour ce système pour linstant."; "bom.empty.message" = "Aucun composant enregistré pour ce système pour linstant.";
"bom.export.pdf.button" = "Exporter en PDF"; "bom.export.pdf.button" = "Exporter en PDF";

View File

@@ -13,6 +13,12 @@
"bom.navigation.title" = "Materiaallijst"; "bom.navigation.title" = "Materiaallijst";
"bom.navigation.title.system" = "Materiaallijst %@"; "bom.navigation.title.system" = "Materiaallijst %@";
"bom.size.unknown" = "Afmeting nog onbekend"; "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.terminals.detail" = "Ring- of vorkklemmen geschikt voor %@-bekabeling";
"bom.empty.message" = "Er zijn nog geen componenten voor dit systeem opgeslagen."; "bom.empty.message" = "Er zijn nog geen componenten voor dit systeem opgeslagen.";
"bom.export.pdf.button" = "PDF exporteren"; "bom.export.pdf.button" = "PDF exporteren";