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 */
/* 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 */;
}

View File

@@ -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] = [

View File

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

View File

@@ -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";

View File

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

View File

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

View File

@@ -11,16 +11,15 @@ 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 {
// Try the simple approach first
return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self)
} catch {
print("Failed to create ModelContainer with simple approach: \(error)")
// Try in-memory as fallback
do {
let schema = Schema([ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self])
@@ -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)
}

View File

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

View File

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

View File

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

View File

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

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 {
@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) {
@@ -40,15 +34,15 @@ struct SettingsView: View {
.font(.headline)
.fontWeight(.semibold)
}
VStack(alignment: .leading, spacing: 8) {
Text("This application provides electrical calculations for educational and estimation purposes only.")
.font(.body)
Text("Important:")
.font(.subheadline)
.fontWeight(.semibold)
VStack(alignment: .leading, spacing: 4) {
Text("• Always consult qualified electricians for actual installations")
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)") {
let settings = UnitSystemSettings()
let manager = StoreKitManager(unitSettings: settings)
return SettingsView()
.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
}
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
}

View File

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

View File

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

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
class UnitSystemSettings: ObservableObject {
@Published var unitSystem: UnitSystem {
didSet {
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() {
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
}
}
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 linstant.";
"bom.export.pdf.button" = "Exporter en PDF";

View File

@@ -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";