diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..93b9b8a --- /dev/null +++ b/CLAUDE.md @@ -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`. diff --git a/Cable.xcodeproj/project.pbxproj b/Cable.xcodeproj/project.pbxproj index 9f166be..2ccfc96 100644 --- a/Cable.xcodeproj/project.pbxproj +++ b/Cable.xcodeproj/project.pbxproj @@ -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 */; } diff --git a/Cable/AmazonAffiliate.swift b/Cable/AmazonAffiliate.swift index 782cf00..016e23c 100644 --- a/Cable/AmazonAffiliate.swift +++ b/Cable/AmazonAffiliate.swift @@ -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] = [ diff --git a/Cable/AppDelegate.swift b/Cable/AppDelegate.swift index 809b3a9..8f14f6f 100644 --- a/Cable/AppDelegate.swift +++ b/Cable/AppDelegate.swift @@ -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) diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index def6e00..ec18114 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -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"; diff --git a/Cable/Batteries/BatteryEditorView.swift b/Cable/Batteries/BatteryEditorView.swift index 2415521..96c1fd6 100644 --- a/Cable/Batteries/BatteryEditorView.swift +++ b/Cable/Batteries/BatteryEditorView.swift @@ -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) diff --git a/Cable/Batteries/SavedBattery.swift b/Cable/Batteries/SavedBattery.swift index 06215e7..095d3c3 100644 --- a/Cable/Batteries/SavedBattery.swift +++ b/Cable/Batteries/SavedBattery.swift @@ -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 } diff --git a/Cable/CableApp.swift b/Cable/CableApp.swift index 2a7126b..ae42d04 100644 --- a/Cable/CableApp.swift +++ b/Cable/CableApp.swift @@ -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) } diff --git a/Cable/Chargers/ChargerConfiguration.swift b/Cable/Chargers/ChargerConfiguration.swift index 4004109..722f534 100644 --- a/Cable/Chargers/ChargerConfiguration.swift +++ b/Cable/Chargers/ChargerConfiguration.swift @@ -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, diff --git a/Cable/Chargers/SavedCharger.swift b/Cable/Chargers/SavedCharger.swift index 5f383c2..4959149 100644 --- a/Cable/Chargers/SavedCharger.swift +++ b/Cable/Chargers/SavedCharger.swift @@ -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, diff --git a/Cable/Loads/CalculatorView.swift b/Cable/Loads/CalculatorView.swift index 03a7592..7d0f0ca 100644 --- a/Cable/Loads/CalculatorView.swift +++ b/Cable/Loads/CalculatorView.swift @@ -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 @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(_ 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 { 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) diff --git a/Cable/Loads/LoadsView.swift b/Cable/Loads/LoadsView.swift index fa7c00b..b756499 100644 --- a/Cable/Loads/LoadsView.swift +++ b/Cable/Loads/LoadsView.swift @@ -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 } diff --git a/Cable/Paywall/CableProPaywallView.swift b/Cable/Paywall/CableProPaywallView.swift deleted file mode 100644 index 93cee4c..0000000 --- a/Cable/Paywall/CableProPaywallView.swift +++ /dev/null @@ -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 = [] - @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(_ result: VerificationResult) throws -> T { - switch result { - case .verified(let signed): - return signed - case .unverified(_, let error): - throw error - } - } - - private func updateCurrentEntitlements() async { - var unlocked: Set = [] - - 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, 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) -} diff --git a/Cable/SettingsView.swift b/Cable/SettingsView.swift index 70b6103..94d66d2 100644 --- a/Cable/SettingsView.swift +++ b/Cable/SettingsView.swift @@ -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) } diff --git a/Cable/StoreKitManager.swift b/Cable/StoreKitManager.swift deleted file mode 100644 index f9c540d..0000000 --- a/Cable/StoreKitManager.swift +++ /dev/null @@ -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 - private weak var unitSettings: UnitSystemSettings? - private var updatesTask: Task? - 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 - } -} diff --git a/Cable/Systems/SystemBillOfMaterialsView.swift b/Cable/Systems/SystemBillOfMaterialsView.swift index ca1788a..c7656a1 100644 --- a/Cable/Systems/SystemBillOfMaterialsView.swift +++ b/Cable/Systems/SystemBillOfMaterialsView.swift @@ -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 } diff --git a/Cable/Systems/SystemComponentsPersistence.swift b/Cable/Systems/SystemComponentsPersistence.swift index 083003a..4d7f25b 100644 --- a/Cable/Systems/SystemComponentsPersistence.swift +++ b/Cable/Systems/SystemComponentsPersistence.swift @@ -263,7 +263,7 @@ struct SystemComponentsPersistence { ) let charger = SavedCharger( name: chargerName, - inputVoltage: 230, + inputVoltage: LocaleDefaults.mainsVoltage, outputVoltage: 14.4, maxCurrentAmps: 30, iconName: "bolt.fill", diff --git a/Cable/UITestSampleData.swift b/Cable/UITestSampleData.swift index ec0c802..d83c9d5 100644 --- a/Cable/UITestSampleData.swift +++ b/Cable/UITestSampleData.swift @@ -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, diff --git a/Cable/UnitSystem.swift b/Cable/UnitSystem.swift index ba48c10..0a562d8 100644 --- a/Cable/UnitSystem.swift +++ b/Cable/UnitSystem.swift @@ -39,22 +39,32 @@ enum UnitSystem: String, CaseIterable { } } +enum LocaleDefaults { + private static let lowVoltageRegions: Set = [ + "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 + } } } diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index d64fd36..96bcebe 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -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"; diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings index 248ffde..59a2ffd 100644 --- a/Cable/es.lproj/Localizable.strings +++ b/Cable/es.lproj/Localizable.strings @@ -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"; diff --git a/Cable/fr.lproj/Localizable.strings b/Cable/fr.lproj/Localizable.strings index 4af7afe..c43ce5d 100644 --- a/Cable/fr.lproj/Localizable.strings +++ b/Cable/fr.lproj/Localizable.strings @@ -13,6 +13,12 @@ "bom.navigation.title" = "Liste de matériel"; "bom.navigation.title.system" = "Liste de matériel – %@"; "bom.size.unknown" = "Taille à déterminer"; +"bom.search.battery" = "%dAh %dV %@ batterie"; +"bom.search.cable.black" = "%@ câble batterie noir"; +"bom.search.cable.red" = "%@ câble batterie rouge"; +"bom.search.device.fallback" = "appareil DC %.0fW %.0fV"; +"bom.search.fuse" = "porte-fusible %dA"; +"bom.search.terminals" = "%@ cosses de câble"; "bom.terminals.detail" = "Cosses à œillet ou à fourche adaptées aux câbles de %@"; "bom.empty.message" = "Aucun composant enregistré pour ce système pour l’instant."; "bom.export.pdf.button" = "Exporter en PDF"; diff --git a/Cable/nl.lproj/Localizable.strings b/Cable/nl.lproj/Localizable.strings index 3d18136..1462605 100644 --- a/Cable/nl.lproj/Localizable.strings +++ b/Cable/nl.lproj/Localizable.strings @@ -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";